ADR-159: One extension, confirmed at the 1.0 API freeze
- Status
-
Accepted
- Date
-
2026-08-11
- Amends
-
ADR-090 (its scheduled 1.0 re-evaluation)
- Authors
-
Netresearch DTT GmbH
Context
ADR-090 decided to ship one extension until 1.0 and to
revisit the split "with or before the 1.0 release", against three criteria.
The 1.0 API freeze is now in progress — the @api snapshot records
constructors, its failures are classified additive vs. breaking, the
deprecation policy is written down and half-enforced
(Deprecation and removal policy), and the support matrix is pinned against
composer.json, ext_emconf.php and the CI matrix
(Support matrix). That triggers the re-evaluation ADR-090 asked
for, so this record answers it rather than deferring again.
Four questions, answered against the code as of 2026-08-11.
Does a consumer need only the provider core?
Architecturally yes; in evidence, unproven.
The seam exists and is enforced in part.
Tests/Architecture/ModuleSeamTest asserts that core depends on neither
the tool/agent/retrieval module (testCoreDoesNotDependOnTheToolModule)
nor the backend UI (testNothingOutsideTheBackendDependsOnIt), on top of
the two directions between the specialized services and the tool module and
the guardrail module's independence from both.
Two edges out of core are neither forbidden by that test nor absent from the
code. Core → guardrail exists in three files
(Service\LlmServiceManager, Provider\Middleware\GuardrailMiddleware,
Service\Streaming\StreamingDispatcher) and is ADR-090's expected
outcome — the safety pipeline is invoked from the send path. Core →
specialized exists in two (Service\Feature\TranslationService and its
interface, both taking
Specialized\Translation\TranslatorRegistryInterface) and is a real seam
crossing. ModuleSeamTest deliberately rules on neither; its docblock says
so. Extracting the core is therefore a packaging change plus those two
edges, not a pure repackaging.
The cost of not splitting is measurable. Classes/ holds 658 PHP files;
the provider core a "only chat and embeddings" consumer uses is the 51 files
under Provider/, the 13 under Service/Feature/ and the middleware
pipeline. ext_tables.sql declares 24 tables, of which 6 are core
(provider, model, configuration, its backend-group join, user budget, service
usage) and 18 belong to the feature modules a core-only consumer never
touches.
What is missing is a consumer. No downstream package in this organisation has asked for a subset, and the argument for a split cannot be built out of a hypothesis about one. ADR-090's second criterion — "a concrete consumer benefits from installing it separately" — is therefore not met.
Are the agent runtime's dependencies heavy?
No — and this is the answer that most changes the picture.
The usual reason to extract an agent runtime is that it drags a dependency
tree behind it. It does not here. composer.json requires seven packages:
php, netresearch/nr-vault, psr/http-client, psr/http-factory,
psr/log, symfony/yaml and typo3/cms-core. Not one of them is
owned by the agent runtime, the tool module or MCP; the core needs all seven
by itself. The 37 files under Service/Agent/ and the 107 under
Service/Tool/ add zero third-party packages.
The genuinely optional couplings are already soft: tpwd/ke_search,
typo3/cms-indexed-search and Solr are require-dev only and guarded at
runtime by ExtensionManagementUtility::isLoaded() in the respective
retrieval backends.
So a split would not shrink anybody's vendor/ directory by a single
package. What it would shrink is installed schema and default attack
surface — real, but addressed today by the tool availability gate
(ADR-120) and the guardrail defaults, not by packaging.
Can MCP be optional?
It already is, at runtime — which is why extracting it buys little.
Classes/Service/Tool/Mcp/ is 10 files and two tables
(tx_nrllm_mcp_server, tx_nrllm_mcp_tool), with a hand-rolled PSR-18
transport and no third-party client library. McpToolProvider::tools()
iterates configured server rows; with no rows it yields nothing, so an
installation that never configures an MCP server runs no MCP code and
exposes no MCP tool.
As a package, MCP is the cleanest seam in the extension — and the least worthwhile: 10 of 658 files, in exchange for a second repository, a second release pipeline and a version-compatibility matrix.
Are there real independent release cycles?
No. Measured over the three most recent minors, by the module
directories each release's Classes/ diff touched:
| Release | Module directories touched |
|---|---|
| 0.25.0 → 0.26.0 | 12+, led by Service/Tool (80 files), Controller/Backend (27),
Domain/ValueObject (19), and also SetupWizard, Evaluation, Agent,
Retrieval, Feature, Provider/Middleware, Domain/Model, Domain/Enum,
Widgets |
| 0.26.0 → 0.27.0 | 12, led by Service/Tool (18), Provider/Middleware (11),
Service/Agent (9), plus SetupWizard, Backend, Telemetry, Health,
Skill, Governance, Context, Analytics |
| 0.27.0 → 0.28.0 | 3 — the one small release in the set |
Every substantial release so far spans core, tools, agent and backend at once. Under a split each of those would have been a coordinated multi-repo release. There is no cadence to separate because no module has one.
Decision
Stay one extension through 1.0. ADR-090's timing — "with or before the 1.0 release" — is met by this record, not by a split: the re-evaluation happened, and its outcome is that the split is still the wrong move.
Of ADR-090's three extraction criteria:
- the 1.0 public-API freeze is planned or in progress — met, that is what this change set is;
- a concrete consumer benefits from installing it separately — not met, no consumer has asked;
- the contract with core has been stable across several releases — unproven, and the instrument that would prove it is one release old. The snapshot did not record constructors until now, and adding them surfaced 70 previously invisible signature lines. No release has yet shipped under a complete frozen surface, so "stable across several releases" cannot honestly be claimed for any module.
The next re-evaluation is due at the first minor after 1.0, by which point the completed snapshot will have covered at least one full release line and the first criterion becomes answerable with evidence rather than with an impression.
Consequences
- ADR-090 stays in force; its split-seam table remains the plan of record and the phpat seam rules remain the thing that keeps it executable. This record is an amendment, not a replacement.
- The README's packaging section keeps saying the same thing, including the "with or before 1.0" timing, which this record satisfies rather than changes.
- The measurable costs of one extension are now written down (18 non-core tables, 658 files) so the next re-evaluation starts from numbers rather than from the same qualitative argument.
- "Heavy agent dependencies" is retired as a split motivation. Should the question return, the argument has to be made on schema footprint or attack surface, because the dependency tree does not support it.
Alternatives considered
- Extract ``nr_llm_tools`` now, since it is the largest module (107
files under
Service/Tool/, 37 underService/Agent/). Rejected: it is also the module that changed most in every recent release, so it is the one whose contract with core is least settled — precisely the case ADR-090 says not to freeze. - Extract MCP as a proof of concept for the split machinery. Rejected: 10 files is not enough load to prove anything about a multi-repo release flow, and it would cost a real repository and pipeline to learn it.
- Defer the re-evaluation again to 1.0 itself. Rejected: ADR-090 says "with or before", the freeze is happening now, and an answer of "still one extension, here is the evidence" is a valid outcome that closes the question instead of carrying it.