TYPO3 LLM extension 

1
Extension key

nr_llm

Package name

netresearch/nr-llm

Version

0.29

Language

en

Author

Netresearch DTT GmbH

License

This document is published under the CC BY 4.0 license.

Rendered

Thu, 13 Aug 2026 07:19:51 +0000


Shared AI foundation for TYPO3. Configure LLM providers once — every AI extension uses them. Supports OpenAI, Anthropic Claude, Google Gemini, Ollama, and more.

LLM backend module dashboard showing provider and model management, AI wizard buttons, and quick-reference code snippets

The Admin Tools > LLM backend module.


Getting started 

📘 Introduction 

Learn what nr-llm is, which providers are supported, and what problems it solves.

📦 Installation 

Install nr-llm via Composer and activate it.


For administrators 

Set up and manage AI providers, models, and configurations through the TYPO3 backend module.

🛠️ Administration guide 

Step-by-step: add providers, fetch models, create configurations and tasks. Includes screenshots of every screen.

✨ AI-powered wizards 

Setup wizard, configuration wizard, and task wizard — let AI generate your config from a plain-language description.

📋 Configuration reference 

Complete field reference for providers, models, configurations, TypoScript settings, security, and caching.

🔐 Permissions & editor access 

Grant editors exactly two capabilities — run prepared tasks and approve suspended runs — through the dedicated AI Tasks module. Nothing is open by default.


For developers 

Build your TYPO3 extension on nr-llm — three lines of dependency injection, no API key handling.

🚀 Integration guide 

Step-by-step tutorial: add AI capabilities to your extension in five minutes.

💻 Developer guide 

LlmServiceManager API, streaming, tool calling, and custom providers.

⚙️ Feature services 

Translation, vision, embeddings, and completion — ready to inject and use.

📚 API reference 

Complete class and method reference for all public services and response objects — with a documented @api stability promise, frozen in a snapshot test.

🏗️ Architecture 

Three-tier configuration hierarchy, provider abstraction, and design decisions.

✅ Testing 

Test infrastructure, mocking LLM services, and CI configuration.


[n] A Netresearch extension 

1

Professional TYPO3 development, AI integration, and enterprise consulting since 2002.


Table of contents

Introduction 

What does it do? 

nr-llm is the shared AI foundation for TYPO3. It lets administrators configure LLM providers once in the backend — and every AI-powered extension on the site uses them automatically.

For extension developers, it eliminates the need to build provider integrations, manage API keys, or implement caching and streaming. Add AI capabilities to your extension with three lines of dependency injection.

For administrators, it provides a backend module tree to manage all AI connections, encrypted API keys, and provider configurations — plus a dedicated AI Tasks module in the Web area where editors run prepared tasks and decide pending approvals, gated by explicit permissions. Switch from OpenAI to Anthropic without touching any extension code.

For agencies, it means consistent AI architecture across client projects, no vendor lock-in, and a local-first option via Ollama for data-sensitive environments.

The extension enables developers to:

  • Access multiple AI providers through a single, consistent API.
  • Switch providers transparently without code changes.
  • Leverage specialized services for common AI tasks (translation, vision, embeddings).
  • Cache responses to reduce API costs and improve performance.
  • Stream responses for real-time user experiences.
  • Store API keys securely as nr-vault identifiers (envelope encryption).

Supported providers 

Provider Models Capabilities
OpenAI GPT-5.x series, o-series reasoning models Chat, completions, embeddings, vision, streaming, tools.
Anthropic Claude Claude Opus 4.5, Claude Sonnet 4.5, Claude Haiku 4.5 Chat, completions, vision, streaming, tools.
Google Gemini Gemini 3 Pro, Gemini 3 Flash, Gemini 2.5 series Chat, completions, embeddings, vision, streaming, tools.
Ollama Local models (Llama, Mistral, etc.) Chat, embeddings, streaming (local).
OpenRouter Multi-provider access Chat, embeddings, vision, streaming, tools.
Mistral Mistral models Chat, embeddings, streaming.
Groq Fast inference models Chat, streaming (fast inference).
Azure OpenAI Same as OpenAI Same as OpenAI.
Custom OpenAI-compatible endpoints Varies by endpoint.

Key features 

AI-powered wizards 

Built-in wizards reduce manual setup to a minimum:

  • Setup wizard guides first-time configuration in five steps (provider, connection test, model fetch, configuration, test prompt).
  • Configuration wizard generates a complete LLM configuration from a plain-language description of your use case.
  • Task wizard creates reusable one-shot prompt templates the same way.
  • Model discovery fetches available models directly from the provider API.

See AI-powered wizards for details and screenshots.

Unified provider API 

All providers implement a common interface, allowing you to:

  • Switch between providers with a single configuration change.
  • Test with different models without modifying application code.
  • Implement provider fallbacks for increased reliability.
Example: Using the provider abstraction layer
// Use database configurations for consistent settings
$config = $configRepository->findByIdentifier('blog-summarizer');
$adapter = $adapterRegistry->createAdapterFromModel($config->getModel());
$response = $adapter->chatCompletion($messages, $config->toOptions());

// Or use inline provider selection
$response = $llmManager->chat($messages, ['provider' => 'openai']);
$response = $llmManager->chat($messages, ['provider' => 'claude']);
Copied!

Specialized feature services 

High-level services for common AI tasks:

CompletionService
Text generation with format control (JSON, Markdown) and creativity presets.
EmbeddingService
Text-to-vector conversion with caching and similarity calculations.
VisionService
Image analysis with specialized prompts for alt-text, titles, descriptions.
TranslationService
Language translation with formality control, domain-specific terminology, and glossaries.

Structured outputs 

Schema-validated JSON from every provider: completeStructured() takes a JSON schema from a named strict subset, enforces it provider-natively where the provider can (OpenAI json_schema, Gemini responseSchema, Ollama format, a forced tool on Claude), validates the response strictly and repairs a mismatch with one controlled round-trip. See ADR-126: A named JSON-Schema subset, enforced strict and ADR-128: Provider-native structured output.

Streaming support 

Real-time response streaming for better user experience:

Example: Streaming chat responses
foreach ($llmManager->streamChat($messages) as $chunk) {
    echo $chunk;
    flush();
}
Copied!

Tool/function calling 

Execute custom functions based on AI decisions:

Example: Tool/function calling
$response = $llmManager->chatWithTools($messages, $tools);
if ($response->hasToolCalls()) {
    // Process tool calls
}
Copied!

Intelligent caching 

  • Automatic response caching using TYPO3's caching framework.
  • Deterministic embedding caching (24-hour default TTL).
  • Configurable cache lifetimes per operation type.

Use cases 

Content generation 

  • Generate product descriptions.
  • Create meta descriptions and SEO content.
  • Draft blog posts and articles.
  • Summarize long-form content.

Translation 

  • Translate website content.
  • Maintain consistent terminology with glossaries.
  • Preserve formatting in technical documents.

Image processing 

  • Generate accessibility-compliant alt-text.
  • Create SEO-optimized image titles.
  • Analyze and categorize image content.

Search and discovery 

  • Semantic search using embeddings.
  • Content similarity detection.
  • Recommendation systems.

Chatbots and assistants 

  • Customer support chatbots.
  • FAQ answering systems.
  • Guided navigation assistants.

Requirements 

  • PHP: 8.2 or higher.
  • TYPO3: v13.4 LTS or v14.3 LTS.
  • HTTP client: PSR-18 compatible (e.g., guzzlehttp/guzzle ).

Provider requirements 

To use specific providers, you need:

Credits 

This extension is developed and maintained by:

Netresearch DTT GmbH
https://www.netresearch.de

Built with the assistance of modern AI development tools and following TYPO3 coding standards and best practices.

Installation 

Quick start 

The recommended way to install this extension is via Composer:

Install via Composer
composer require netresearch/nr-llm
Copied!

After installation:

  1. Activate the extension in Admin Tools > Extension Manager.
  2. Configure providers and API keys in Admin Tools > LLM > Providers.
  3. Define available models in Admin Tools > LLM > Models.
  4. Create configurations in Admin Tools > LLM > Configurations.
  5. Clear caches.

Composer installation 

Requirements 

Ensure your system meets these requirements:

  • PHP 8.2 or higher.
  • TYPO3 v13.4 LTS or v14.3 LTS.
  • Composer 2.x.
  • netresearch/nr-vault ^0.14.0 (required for API key encryption; installed automatically via Composer).

Installation steps 

  1. Add the package

    Install via Composer
    composer require netresearch/nr-llm
    Copied!
  2. Activate the extension

    Navigate to Admin Tools > Extension Manager and activate EXT:nr_llm.

  3. Configure API keys

    Use the setup wizard at Admin Tools > LLM > Setup Wizard to auto-detect your provider and discover models.

    LLM setup wizard

    The setup wizard guides you through provider connection, model discovery, and configuration.

    See Configuration reference for detailed setup instructions.

  4. Clear caches

    Flush all caches
    vendor/bin/typo3 cache:flush
    Copied!

Manual installation 

If you cannot use Composer:

  1. Download the extension from the TYPO3 Extension Repository (TER).
  2. Extract to typo3conf/ext/nr_llm.
  3. Activate in Admin Tools > Extension Manager.
  4. Configure API keys and settings.

Database setup 

The extension creates the following database tables automatically:

Table Purpose
tx_nrllm_provider Stores API provider connections with encrypted credentials.
tx_nrllm_model Stores available LLM models with capabilities and pricing.
tx_nrllm_configuration Stores use-case-specific configurations with prompts and parameters.
tx_nrllm_task Stores one-shot prompt tasks for common operations.
tx_nrllm_prompttemplate Stores reusable prompt templates with versioning and performance tracking.
tx_nrllm_service_usage Tracks specialized service usage (translation, speech, image).

Run the database compare tool after installation:

Set up extension database tables
vendor/bin/typo3 extension:setup nr_llm
Copied!

Cache configuration 

The extension uses TYPO3's caching framework. Cache configuration is set up automatically — no backend is hardcoded. TYPO3 uses your instance's default cache backend, so Redis, Valkey, or Memcached work transparently if configured.

To override the cache backend specifically for nr-llm:

config/system/additional.php
use TYPO3\CMS\Core\Cache\Backend\RedisBackend;

$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']
    ['cacheConfigurations']['nrllm_responses']
    ['backend'] = RedisBackend::class;
Copied!

Upgrading 

From previous versions 

  1. Backup your database before upgrading.
  2. Run Composer update:

    Update the extension
    composer update netresearch/nr-llm
    Copied!
  3. Run database migrations:

    Update database schema
    vendor/bin/typo3 database:updateschema
    Copied!
  4. Clear all caches:

    Flush all caches
    vendor/bin/typo3 cache:flush
    Copied!

Breaking changes 

Check the Changelog for breaking changes between versions.

Uninstallation 

To remove the extension:

  1. Deactivate in Admin Tools > Extension Manager.
  2. Remove via Composer:

    Remove the extension
    composer remove netresearch/nr-llm
    Copied!
  3. Clean up database tables if desired:

    Drop extension database tables
    DROP TABLE IF EXISTS tx_nrllm_provider;
    DROP TABLE IF EXISTS tx_nrllm_model;
    DROP TABLE IF EXISTS tx_nrllm_configuration;
    DROP TABLE IF EXISTS tx_nrllm_configuration_begroups_mm;
    DROP TABLE IF EXISTS tx_nrllm_task;
    DROP TABLE IF EXISTS tx_nrllm_prompttemplate;
    DROP TABLE IF EXISTS tx_nrllm_service_usage;
    Copied!
  4. Remove any TypoScript includes referencing the extension.

Administration 

This guide walks you through managing AI providers, models, configurations, and tasks in the TYPO3 backend. It also covers the AI-powered wizards that automate most of the setup.

The LLM backend module 

All AI management happens in Admin Tools > LLM; editors run prepared tasks and decide approvals in the separate Web > AI Tasks module (Backend user permissions). The Overview is a guided starting point:

  • a usage & cost band across the top — 30-day cost, requests and tokens, the per-provider request mix, and a daily-requests sparkline (empty until the first request);
  • a unified Set up & manage grid where each module card carries its own setup state — green when it is configured, a blue Next flag on the single recommended step, and Empty on an optional module with no entries yet — so the next action is always visible without a separate wizard. Each card links to its module;
  • the Providers card shows a live, token-free reachability indicator per configured provider (a model-list/health ping, never a completion);
  • a For developers section showing how to call the same configuration from PHP via LlmServiceManager.

The Overview's docheader carries a Governance tab — the read-only effective policy readout (ADR-140). It lists the four governance keys that carry a decision (privacy.level, privacy.retentionDays, tools.dataClassEnforcement, skills.minTrustLevel) with the value the runtime applies right now and the class that resolved it. Two things it deliberately does not do: it never changes a value — instance-wide keys are set in the Install Tool under Settings > Extension Configuration > nr_llm — and it never shows a value it did not get from a resolver, so a row that cannot be answered reads unknown rather than a default. Because the reads go through the runtime resolvers, the tab shows what is in force: a mistyped tools.dataClassEnforcement reads enforce, because the gate is fail-closed (ADR-113). The keys, their fallbacks and recommended settings are documented under Effective policy.

Two rows say more than their value alone:

  • tools.dataClassEnforcement = observe is annotated as applying to built-in tools only. Tools reached through an MCP server are always enforced against the trust-zone ceiling (ADR-115), so an MCP tool can be dropped while this row reads observe.
  • Every privacy.retention.<category> override that deviates from privacy.retentionDays gets its own row underneath it. Categories left at 0 resolve to the global window and are not listed — see Data retention & purge for the full set.
The LLM Overview — a usage-and-cost band, a status-coloured module card grid, and a developer section

The LLM Overview: the usage & cost band, the state-coloured Set up & manage grid, and the For developers section.

The admin module tree has fourteen sections accessible from the left-hand navigation:

  • Overview — guided dashboard: usage & cost, per-module setup state, and the developer guide
  • Providers — API connections
  • Models — available LLM models
  • Configurations — use-case presets
  • Tasks — one-shot prompt templates
  • Snippets — tagged reusable prompt fragments
  • Get Started — pick a use case and install a matching pack of configuration, tasks and snippets (admin-only)
  • Setup wizard — guided provider, model and configuration setup (admin-only)
  • Skills — GitHub-hosted SKILL.md sources (admin-only)
  • Tools — enable or disable the agent tools (admin-only)
  • MCP servers — configure external MCP servers and import their tool catalogues (admin-only)
  • Playground — run the agent tool loop interactively (admin-only)
  • Agent runs — review and decide runs paused for approval or input (admin-only)
  • Analytics — usage and cost dashboard (admin-only)

Editors do not use this tree: their surface is the separate Web > AI Tasks module, opened per backend group through the permission grants.

Managing providers 

Providers represent connections to AI services. Each provider stores an API endpoint, encrypted credentials, and adapter-specific settings.

Provider list showing adapter type, endpoint URL, API key status, and actions

The provider list with connection status indicators and action buttons.

Adding a provider 

  1. Navigate to Admin Tools > LLM > Providers.
  2. Click Add Provider.
  3. Fill in the required fields:

    Identifier
    A unique slug for programmatic access (e.g., openai-prod, ollama-local).
    Name
    A display name for the backend (e.g., OpenAI Production).
    Adapter Type
    Select the provider protocol. Available adapters: openai, anthropic, gemini, ollama, openrouter, mistral, groq, azure_openai, custom.
    API Key
    Your API key. Stored securely via nr-vault envelope encryption. Leave empty for local providers like Ollama.
  4. Optionally set the endpoint URL, organization ID, timeout, and retry count.
  5. Click Save.

Setting the key from the command line 

An unattended install cannot operate the wizard. nrllm:provider:set-key does the same job for a provider record that already exists, reading the key from STDIN:

Store a key for the "openai" provider
printf '%s' "$OPENAI_API_KEY" | \
    vendor/bin/typo3 nrllm:provider:set-key openai
Copied!

The key is never accepted as an argument — that would put it in the process list and the shell history. A terminal is refused rather than read, so a provisioning script fails visibly instead of hanging on a prompt.

Running it again for the same provider replaces the stored key and keeps the identifier, so anything already referring to that identifier — including providers.openai.apiKeyIdentifier in the extension configuration, which the speech and image services read — keeps working. See ADR-124.

Testing a connection 

After saving a provider, click Test Connection to verify the setup. The test makes an HTTP request to the provider API and reports:

  • Connection status (success or failure).
  • Available models (if the provider supports listing).
  • Error details on failure.
Provider test modal showing successful connection to Local Ollama

Successful connection test for the Local Ollama provider.

Editing and deleting providers 

  • Click a provider row to edit its settings.
  • Use the Delete action to remove a provider. Models linked to a deleted provider become inactive.

Managing models 

Models represent specific LLM models available through a provider (e.g., gpt-5, claude-sonnet-4-6, llama-3).

Model list showing capabilities, context length, pricing, and default status

The model list with capability badges, context length, and cost-per-token columns.

Adding a model manually 

  1. Navigate to Admin Tools > LLM > Models.
  2. Click Add Model.
  3. Fill in the required fields:

    Identifier
    Unique slug (e.g., gpt-5, claude-sonnet).
    Name
    Display name (e.g., GPT-5 (128K)).
    Provider
    Select the parent provider.
    Model ID
    The API model identifier as the provider expects it (e.g., gpt-5.3-instant, claude-sonnet-4-6).
  4. Optionally set capabilities (chat, completion, embeddings, vision, streaming, tools), context length, max output tokens, and pricing.
  5. Click Save.

Fetching models from a provider 

Instead of adding models manually, use the Fetch Models action to query the provider API and auto-populate the model list:

  1. Ensure the provider is saved and the connection test passes.
  2. On the model list or model edit form, click Fetch Models.
  3. The extension queries the provider API and creates model records with capabilities and metadata pre-filled.

This is the recommended approach — it ensures model IDs match the provider exactly and keeps your catalogue current as providers release new models.

Which capabilities the provider actually confirmed 

A capability badge in the model list says that the model has the capability, not who said so. Those are different claims, and the list separates them.

A grey badge means a live answer from the provider's own model endpoint declared that capability, and the row says when. A yellow badge with a question mark means nobody confirmed it: either an administrator ticked it by hand, or it came from the static model catalogue bundled with the extension. Hover the badge for the reason.

Expect the yellow badge for OpenAI, Anthropic, Groq and the Gemini models the extension knows by name, even against a reachable API. Those model endpoints list model ids and no capabilities, so the tokens come from the bundled catalogue on a live run exactly as on an unreachable one, and the badge says so rather than borrowing the provider's authority. Mistral, OpenRouter, Ollama and Gemini releases newer than this extension do report capabilities per model, and those confirm to grey.

Use the Confirm capabilities row action to ask the provider. It runs the same discovery the wizard uses, records what came back, and refreshes the confirmation date. A model the provider does not list is reported as such — that is not a confirmation, so nothing is stored.

Confirming never edits what you declared. A capability you ticked that the provider does not advertise stays on the model and simply stops borrowing the provider's authority for it. That matters most for a model created from the bundled catalogue: nothing about it was ever checked against the live API until you confirm it.

The three underlying fields — what was confirmed, when, and whether the answer was live or from the catalogue — are also visible read-only on the Capabilities tab of the model record.

Routing does not use this. Eligibility below still reads the declared capabilities, confirmed or not: making it depend on confirmation would silently drop every model an administrator declared by hand.

Which model a criteria-mode configuration picks 

A configuration set to Dynamic (Criteria) names no model. One is chosen per call from the models that are active, in two steps that never mix.

Eligibility is a hard yes or no. A model is considered only if it declares every capability the criteria require, uses a permitted adapter type, meets the minimum context length, stays within the cost ceiling, and — for the operation being run — does not declare capabilities that exclude it. A model refused here cannot come back: nothing about its speed, price or quality is even looked at.

Ranking orders what is left. The provider Priority you set decides first, always. Below it, the extension configuration option Routing policy (category routing) chooses what else counts:

Provider priority
The default. Nothing beyond your priority, the default-model flag and the sorting order, plus cost where the criteria set prefer lowest cost. This is what the extension has always done.
Balanced, Quality, Economy
Add measured signals — evaluation quality scores and recent provider health — weighted differently. Economy also weighs cost when the criteria did not ask for it.

The measured modes are opt-in on purpose: they change which model serves a call, so an upgrade never switches them on for you. Two rules make them safe to try. Your provider priority is never overruled by a measurement — a priority is an instruction, a score is evidence. And a model nobody has measured is not punished for it: an absent signal is skipped, not counted as zero.

If a criteria-mode configuration selects nothing, the cause is usually that every matching model declares it cannot serve that operation. The extension says so explicitly rather than letting the provider fail with an opaque error.

Managing configurations 

Configurations define use-case-specific presets that combine a model with a system prompt and generation parameters. Extension developers reference configurations by identifier in their code.

Configuration list with model assignment, use-case type, and parameter summary

The configuration list showing each entry's linked model, use-case type, and key parameters.

Adding a configuration manually 

  1. Navigate to Admin Tools > LLM > Configurations.
  2. Click Add Configuration.
  3. Fill in the required fields:

    Identifier
    Unique slug for programmatic access (e.g., blog-summarizer).
    Name
    Display name (e.g., Blog Post Summarizer).
    Model
    Select the model to use.
    System Prompt
    The system message that sets the AI's behavior and context.
    Prompt snippet tags
    Optional. Active prompt snippets carrying any ticked tag are appended to the system prompt of every request made with this configuration.
  4. Optionally adjust temperature (0.0-2.0), top_p, frequency/presence penalty, max tokens, and use-case type (chat, completion, embedding, translation).
  5. Click Save.

Importing configuration presets 

Extensions consuming nr_llm can declare the configurations they need as presets (ADR-056). When at least one declared preset has not been imported yet, the configuration list shows a Pending presets panel above the records.

Each pending preset row shows the preset's name, identifier, description, and a requirement check:

  • Satisfiable — an active model currently matches the preset's requirements; the model that would be used right now is named. Click Import to create the configuration record with one confirmation.
  • Not satisfiable — no active model matches; the first missing requirement is named. The Import button stays disabled until you configure a matching provider and model.

Imported records are normal criteria-mode configurations: the model is resolved at runtime from the providers and models you configured, and you can edit or delete the record like any other. The panel disappears once no presets are pending.

If a consuming extension later changes its preset declaration, the imported configuration is flagged with a Preset changed badge in the list. The record is never updated automatically.

Next to the badge, Review update opens a dialog that lists, field by field, the record's current value against the changed declaration. Confirming with Apply update overwrites those record fields with the declared values and clears the badge. Your own settings are preserved: whether the configuration is active or the default, its backend-group assignment, and its fallback chain are never changed. If you switched the configuration to fixed model selection, or the changed requirements no longer match any active model, the update is refused with the reason shown.

Testing a configuration 

Click Test Configuration on any row. The test sends a short prompt to the model and shows the response, model ID, and token usage.

Configuration test modal showing successful response from Qwen 3 via Ollama

Successful configuration test with token count.

Editing configurations 

Click a configuration row to edit. Changes take effect immediately for any extension code that references this configuration's identifier — no code deployment needed.

Managing tasks 

Tasks are one-shot prompt templates that combine a configuration with a specific user prompt. They provide reusable AI operations that editors or extensions can execute with a single call.

Task list showing task name, linked configuration, description, and actions

The task list with each task's assigned configuration and action buttons.

Adding a task manually 

  1. Navigate to Admin Tools > LLM > Tasks.
  2. Click Add Task.
  3. Fill in the required fields:

    Name
    Display name (e.g., Summarize Article).
    Configuration
    Select the LLM configuration to use.
    User Prompt
    The prompt template. Use {placeholders} for dynamic values.
  4. Add a description so other admins understand what the task does.
  5. Click Save.

Executing a task 

Click Run on any task to open the execution form. It shows the configuration, model, parameters, input field, and prompt template.

Task execution form showing configuration details, input field, and prompt template

The task execution form for "Analyze System Log Errors" with the Ollama provider and Qwen 3 model.

Example tasks:

  • Summarize content — condense long articles.
  • Generate meta descriptions — SEO optimization.
  • Translate text — one-click translation.
  • Extract keywords — pull key terms from content.

The editor module 

Editors do not need backend administrator rights to run tasks: the dedicated Web > AI Tasks module (ADR-131) lists every active task and opens a slim run form — without any of the management affordances of this admin module. Access takes both switches described under Backend user permissions: the module permission on the backend group AND the Execute AI tasks grant. Every run is pre-flighted against the user's own usage budget. Tasks with the table input type are not offered to editors — their database record picker stays admin-only.

The editor-facing AI Tasks module listing runnable tasks grouped by category

The AI Tasks module as an editor sees it: runnable tasks grouped by category, plus the Approvals shortcut for users holding the approval grant.

The editor run form with input area and output panel, without management links

The editor run form: input, execute, output — the configuration is shown as a read-only summary line.

Managing prompt snippets 

Prompt snippets are small named prompt fragments — personas, tones of voice, target audiences, image styles, layouts — that editors manage centrally. Consuming extensions (for example nr_repurpose) query snippets by tag and compose them into their prompts.

Snippets are deliberately not prompt templates: a prompt template is a complete, versioned prompt with model parameters, while a snippet is a reusable building block without any model binding.

Adding a snippet 

  1. Navigate to Admin Tools > LLM > Snippets.
  2. Click New Snippet.
  3. Fill in the fields:

    Identifier
    Unique technical identifier (e.g., persona-friendly-expert).
    Name
    Display name (e.g., Friendly Expert).
    Tags
    Comma-separated tags consuming extensions search for (see below).
    Snippet text
    The prompt fragment itself.
    Metadata (JSON)
    Optional JSON object with extra settings.
  4. Click Save.

Tag convention 

Tags are free-form, comma-separated strings. There is no fixed vocabulary — consuming extensions agree on tags with the editors. Matching is exact per tag and case-insensitive: the tag style does not match a snippet tagged lifestyle.

Established tags so far:

Tag Used for
audience Target audience descriptions
tone_of_voice Tone-of-voice instructions
persona Writing/speaking personas
layout Layout instructions (e.g. for slides)
style Image / visual style descriptions

Persona snippets may carry a voice hint in their metadata so speech features can pick a matching text-to-speech voice:

Metadata of a persona snippet
{"voice": "nova"}
Copied!

Attaching snippets to a configuration 

A configuration can select snippets by tag. Every request made with that configuration then carries them — chat, single-prompt completion, streaming and agent runs alike — without any extension code.

  1. Navigate to Admin Tools > LLM > Configurations and edit a configuration.
  2. Open the Parameters tab.
  3. Tick the wanted tags under Prompt snippet tags. The list offers the tags the snippet records actually carry.
  4. Click Save.

The active snippets carrying any ticked tag are appended to the configuration's System Prompt, each as a NAME: block separated by a blank line, in the order the tags are listed. A snippet carrying two ticked tags is added once. A tag no snippet carries adds nothing — there is no error, matching the free-tag model above.

Effective system prompt of a configuration with the tags persona and tone_of_voice
You are a helpful assistant.

Nova persona:
You are Nova, a friendly expert.

Formal tone:
Use a formal, professional tone of voice.
Copied!

Two limits are worth knowing:

  • Only active snippets are composed; hiding a snippet removes it from every configuration that selects its tag.
  • A caller that supplies its own system message replaces the configuration's system prompt for that call, and with it the snippet block. This is the documented per-call precedence and predates this field.

Using snippets from an extension 

Query snippets by tag through the public PromptSnippetRepository and compose the selected fragments with the PromptSnippetComposer:

Composing snippets into a prompt
$audiences = $this->promptSnippetRepository
    ->findActiveByTag('audience');
$tones = $this->promptSnippetRepository
    ->findActiveByTag('tone_of_voice');

$sections = $this->promptSnippetComposer->composeSections([
    'TARGET AUDIENCE' => $audiences[0] ?? null,
    'TONE OF VOICE' => $tones[0] ?? null,
]);
Copied!

composeSections() renders each non-null snippet as a LABEL: block followed by the snippet text, joined by blank lines. Null entries and empty snippets are skipped.

findActiveByTag() filters on is_active only — like every repository here it ignores the enable fields, so hidden records are part of its result. Configurations drop them when they compose their prompt; an extension that queries directly has to skip isHidden() snippets itself if it wants the same behaviour.

See ADR-031 for the design rationale.

Get Started: use-case packs 

Admin Tools > LLM > Get Started asks what you want to do before it asks anything technical — editorial assistance, translation, metadata, media accessibility, agent workflows, developer integration — and answers with a use-case pack: a named bundle of one configuration, several tasks and several prompt snippets, plus the governance posture and the tool groups the pack was written for.

The module is admin-only. The records it creates are ordinary records an administrator could create by hand; nothing marks them as pack-owned.

Editorial Starter is the only pack currently shipped. The other five use cases are listed with a "no pack yet" note and a link to the setup wizard, which stays the technical route and is linked from every screen here.

What an install creates 

Pick a use case, then a pack, and the plan screen lists every record the pack declares with its identifier and its current state — Would be created or Already there. Nothing is written until you press Create the missing records.

Editorial Starter declares:

Record What it is
Configuration nr_llm.editorial_starter Low temperature, room for a medium-length article, requires nothing but the chat capability — so it installs against a local Ollama as readily as against a hosted provider. It sets no tool group restriction of its own.
Four tasks Summarise for a teaser, rewrite for clarity, proofread, suggest headlines. Each takes the text through {{input}}.
Two snippets House style (tag tone_of_voice) and Target audience (tag audience). Both are meant to be edited — they are the pack's placeholders for your own voice.

The pack's configuration is a configuration preset, not a second kind of record, so it also appears in the Configuration module's Pending presets card and can be imported there instead.

What a pack recommends but never applies 

  • Governance posture. The pack names the posture its content was written for (Editorial Starter: controlled cloud). Installing changes no governance value; the screen links to the governance readout where the posture in force is shown.
  • Tool groups. The pack names the groups its tasks benefit from (Editorial Starter: content). Enabling a tool group stays an administrator decision in the Tools module.
  • Nothing about providers or API keys. The pack states model requirements. When no active model satisfies them, the plan says which requirement is missing and links to the setup wizard.

A pack installs no skills. A skill carries provenance and a trust level from the source it was synced from, and a pack can produce neither; add a skill source instead.

Installing again 

"Already installed" means a record with that identifier exists — nothing more. The installer never overwrites and never compares contents, so:

  • A task you renamed or whose prompt you rewrote is left exactly as it is.
  • A record you disabled still counts as installed, so a second install cannot quietly resurrect what you switched off.
  • A tag the configuration already selects is not written again.

A second install therefore reports records created: 0. The one case where it still has work is a configuration created by importing the preset in the Configuration module: its records exist but its snippet-tag selection does not yet include the pack's tags, so the confirm button stays offered and the success message names the tags it added.

A pack cannot be uninstalled. Nothing marks a record as pack-owned — that is what makes the installed records ordinary — so removing them is deleting records, one by one, like any other.

The full rationale is in ADR-163.

Managing skills 

Skills are GitHub-hosted SKILL.md files — a YAML front-matter block with a name and description plus a markdown body — that nr-llm can ingest, review, and (from Plan 1b) inject into prompts. You add a skill source that points at GitHub, sync it, and then enable the individual skills you want.

Skill management is admin-only. It lives in Admin Tools > LLM > Skills and is not delegated to other backend groups: a skill body becomes prompt context, so the two skill tables are treated as a privilege-escalation surface.

Source types 

A source has one of three types:

single_file
One SKILL.md at a fixed path in a repository. A single, explicit admin act — its skill may default to enabled.
repo
A whole repository. Every SKILL.md under the repo root, skills/<name>/, .claude/skills/<name>/ or <plugin>/skills/<name>/ is discovered. Discovered skills arrive disabled for review.
marketplace
An Anthropic marketplace.json index that lists plugins pointing at further repositories. Each entry is expanded with the repo flow. All discovered skills arrive disabled.

Adding a source 

  1. Navigate to Admin Tools > LLM > Skills.
  2. Click New Skill Source.
  3. Fill in the fields:

    Title
    Display name for the source list.
    Type
    single_file, repo or marketplace (see above).
    URL
    The GitHub URL the type expects (the SKILL.md URL, the repository URL, or the marketplace.json URL).
    Ref
    A branch or tag (for example main or v1.2.0). It is resolved once to an immutable commit SHA at sync time; all bodies are then fetched by that SHA, never by the moving branch.
  4. Click Save.

The pinned_sha, sync_status, sync_error and last_synced fields are managed by the sync run and shown read-only.

GitHub token and rate limits 

Unauthenticated GitHub API access is limited to 60 requests per hour, which is quickly exhausted by a repo or marketplace sync. Add a personal access token (a read-only, public-repo token is enough) to raise the limit and to read private repositories.

  • The token is set through the Set token action on a source, not typed into a FormEngine field. It is stored as an nr-vault UUID (envelope-encrypted), mirroring provider API-key storage — never as plaintext in TCA, YAML or the database.
  • When a sync hits the rate limit (HTTP 403 with no remaining quota), the source is set to sync_status = error carrying the reset time; state is not partially corrupted. Add a token and re-sync.

Host-allowlist prerequisite 

nr-llm enforces an app-level GitHub allowlist on every skill request: the scheme must be https and the host must be one of github.com, raw.githubusercontent.com, api.github.com or codeload.github.com. This is separate from, and in addition to, the nr-vault SSRF guard.

On hardened instances that restrict outbound HTTP through the global HTTP/allowed_hosts SSRF setting, those four GitHub hosts must be on that list, otherwise every sync fails closed. This is a deliberate prerequisite — nr-llm never silently bypasses the SSRF guard.

Syncing and the review flow 

The Skills module showing a synced marketplace source and the discovered skills with their support badge and enabled state

The Skills module — the Sources table (type, sync status, last synced, per-source actions) above the discovered Skills with their partial / full support badge and enabled state.

  1. On a source, click Sync. The source moves through never_syncedsyncingok / partial / error. The syncing state also acts as a lock: a second concurrent sync on the same source is refused.
  2. partial means the per-sync file-count or wall-time bound was reached (large marketplaces); the skills fetched so far are stored.
  3. Discovered skills from repo and marketplace sources are created disabled by default. Review each one, then toggle it on with Enable.
  4. Re-sync never silently changes an enabled skill. If a re-sync recomputes a different body_checksum for an enabled skill, nr-llm auto-disables it and surfaces a diff (Review changes) so you re-confirm before it is used again. Accepting the diff re-pins the SHA atomically.
  5. A skill that disappeared upstream is marked orphaned and disabled, never silently dropped, so attachments (Plan 1b) do not vanish.

Deleting a source cascade-deletes its skills.

The partial support badge 

Each skill carries a support badge:

full
The skill is plain front-matter and prose.
partial
The body or front-matter references scripts, references/, assets/ or an allowed-tools declaration.

See ADR-035 for the full design and security rationale.

Attaching skills and injecting them into prompts 

Enabled, non-orphaned skills can be attached to a Task and/or an LLM configuration via the Skills field on those records (only enabled skills are offered). At execution time, for text-generation operations only — completion, translation and task execution; never embeddings, vision or speech — nr-llm composes the attached skills into a delimited block and prepends it to the user prompt. The configuration system_prompt is never modified.

Composition rules:

  • Precedence. Configuration skills are the baseline, task skills are additive; the set is the union deduped by source + identifier (the configuration wins on a duplicate). The configuration block renders first.
  • Budget. The block is bounded by a conservative character budget; when it is exceeded, task-additive skills are dropped before configuration-baseline skills and each drop is logged.
  • Integrity. Each skill's body checksum is re-verified at injection time; a mismatch (tampering or a stale row) drops that skill — it is never injected.
  • Untrusted output. Skill prose is third-party text; output produced under its influence is treated as untrusted and escaped/sanitized where it is stored or rendered. Message role is defense-in-depth, not a trust boundary.

See ADR-036 for the injection design.

Isolation controls: trust, fingerprint, injection scan, audit 

On top of the SHA-pin and checksum controls above, each source and skill carries the isolation controls introduced in ADR-061.

Publisher trust level. Every source is classified — untrusted (the default, for anonymous public GitHub content), community, verified or first_party (operator-controlled). This is provenance, independent of the partial support badge. Each synced skill denormalises its source's level, so re-classifying a source takes effect on the next sync. The instance-wide floor skills.minTrustLevel (extension configuration, default untrusted) gates use: a skill below the floor is dropped from both prompt injection and the allowed-tools union. Raising the floor to verified therefore hides every community/untrusted skill without deleting it. Trust is separate from the enabled = false default — an untrusted skill still needs an explicit enable. An unreadable or mistyped floor falls back to untrusted; the value actually in force is shown under Effective policy.

Skill-block byte budget. skills.maxBytes (extension configuration, default 24000) caps the composed block that is prepended to the user prompt. The measure is bytes, not tokens — a deliberate over-estimate, since no tokenizer is available. When the block exceeds the budget, skills are dropped from the tail first: task-additive skills go before the configuration baseline, and every drop is logged as a warning. Lower the value to reserve more of the model's context window for the conversation itself; raise it if a large configuration baseline is being trimmed. An empty, non-numeric or zero value falls back to 24000 — the cap cannot be switched off, so an emptied field never puts an unbounded block on the wire. The budget is instance-wide and independent of the model's context window; the per-request window bound is handled separately by the context-window manager (ADR-107).

The budget bounds the prompt block only, not the allowed-tools union (Gating tools). The union is computed over the effective skills before the block is assembled, so a skill dropped for the budget still grants its tools while its usage rules stay out of the prompt. That is deliberate — a budget-aware union would widen the gate, because dropping the last declaring skill removes the restriction entirely. Watch the drop warnings when lowering the value: they name every skill whose prose stopped shipping while its tools kept being offered.

Manifest fingerprint (optional). A source may declare an expected_fingerprint: the sha256 its whole skill set must hash to. When set, the digest is recomputed at sync and verified before anything is materialised; a mismatch fails closed (no skill is imported, the source goes to error) and leaves the last known-good skills untouched. Leave it empty to rely on the commit-SHA pin alone. This binds the reviewed bytes to a publisher-declared identity beyond "these bytes from this URL"; it is a declared digest, not a public-key signature.

Prompt-injection scan. Each body is scanned at ingest for known injection signatures. A high-confidence jailbreak marker (e.g. "ignore all previous instructions", role reset, chat-template control tokens) force-disables the skill at import — even a single-file source that would otherwise default enabled — and must be re-reviewed before enabling. Lower-confidence findings are recorded on the skill (Injection scan findings) for review without blocking.

Immutable audit trail. Every ingest, enable, disable and fail-closed rejection is written to tx_nrllm_skill_audit with who / when / source / SHA / checksum / trust level / scan result. The trail is append-only — the application never updates or deletes a row — so the provenance of any skill that can reach a prompt is reconstructable after the fact.

Running tools 

Tools are small, admin-curated PHP functions the model may call mid-generation. Where a normal completion answers in one shot, a tool run is a bounded agent loop: the model may ask to call a tool, nr-llm executes it, feeds the result back, and re-asks — until the model answers or an iteration cap is reached. The v1 consumer is the interactive Tool Playground.

The Tool Playground — the only surface that runs the agent TOOL loop — is admin-only (editors run one-shot tasks and decide approvals in Web > AI Tasks, which never executes tools directly). The runtime itself applies a two-tier gate: each tool declares requiresAdmin(), and ToolLoopService drops admin-only tools when the acting backend user is not an administrator. Most built-in tools require admin because a tool runs with full TYPO3 privileges, has no per-record authorization, and its return value egresses both to the configured LLM provider and to the rendered backend output; only a few read-only, scope-limited tools are offered to non-admin users.

The built-in tools 

nr-llm ships forty-one read-only tools and five writing tools. Each is a reference implementation of the security contract: model-chosen arguments are validated and scoped, volumes are capped, and secret-bearing output is either redacted or gated behind a separate _raw variant. Thirty-eight ship enabled; the three unredacted _raw variants (get_env_raw, get_php_info_raw and list_be_users_raw) and all five writing tools (update_page_metadata, set_file_alternative_text, move_content_element, create_content_element_draft, create_translation_draft) ship disabled and must be enabled deliberately. Many require admin; the read-only structure, content and file tools (get_pagetree, get_tca, get_full_tca, get_table_schema, get_flexform_schema, fluid_resolve, search_records, get_page_content, read_records, get_record_history, resolve_url, validate_tca, list_fal_storages, browse_fal_folder, search_fal_files, get_fal_references, find_missing_files) are offered to non-admin backend users — those self-enforce the acting user's TYPO3 permissions (page-show rights, tables_select) inside the tool, so a non-admin only ever sees what the backend already grants them (see ADR-042).

The two tools below are the fullest illustrations of the contract:

fetch_logs
Returns the most recent sys_log entries, newest first, with an optional PSR level filter and a limit (default 20, hard-capped at 50). Personally-identifying fields — the client IP, the backend user id and the serialized payload — are redacted by omission, because the result egresses to the external provider.
read_fal_asset_meta
Returns read-only metadata (file name, MIME type, size, title, alternative text) for a single managed file (sys_file) by its uid. The uid is model-chosen and therefore injection-steerable, so the lookup is storage-scoped (default: the default storage). A uid in a non-permitted storage returns the same neutral "not found or not permitted" string as a missing uid — the model cannot enumerate arbitrary files.

The remaining tools follow the same pattern:

list_fal_storages
The file storages this run may touch (uid, name, driver, status flags). The effective set is the configured allow-list, intersected for non-admins with their file mounts; the server-side base path is never part of the output.
browse_fal_folder
One FAL folder: subfolders (with file count), then files with size and MIME type. Storage-relative identifiers only; anything unresolvable collapses into one neutral denial. Capped at 100 entries.
search_fal_files
Substring search over file name and metadata title/alternative within the accessible storages. %/_ in the query match literally; missing files are excluded.
get_fal_references
Where a file is used: sys_file_reference rows as `table:uid (field)`, hidden references marked. Soft references (RTE links, plain URLs) are not tracked — stated in the output so "no references" is never read as "safe to delete". Non-admins only see references from tables they may read.
find_missing_files
sys_file records whose physical file is gone (missing = 1) — the "broken image" diagnosis. The total count is always reported next to the capped listing.
get_env / get_env_raw
Process environment variables. get_env redacts secret-looking values (password, token, key, secret, salt, DSN, …); get_env_raw returns them unredacted (database password, encryption key) and ships disabled.
get_php_info / get_php_info_raw
PHP runtime configuration. get_php_info is redacted; get_php_info_raw returns the full, secret-bearing phpinfo detail and ships disabled.
get_pagetree
The backend page tree (uid, title, doktype) as a depth-indented outline; deleted and hidden pages are excluded — structure only, no content.
get_tca
The TYPO3 TCA schema: with no argument it lists the configured table names; with a table argument it returns that table's field definitions.
list_be_groups
The backend user groups (uid, title).
list_be_users / list_be_users_raw
Backend users. list_be_users omits credentials (password hashes and MFA secrets are never included); list_be_users_raw returns the full non-credential profile columns and ships disabled.
search_records
Full-text search across the tables that define TCA searchFields. Returns compact table:uid hits with a short excerpt around the match. Credential and nr-llm configuration tables are never searched; non-admins are limited to their tables_select tables and to hits on pages they may show.
get_page_content
One page's header data plus its content elements in column/sorting order (uid, colPos, CType, header, a short bodytext excerpt). Non-admins need page-show permission; only admins see hidden elements (marked [hidden]).
read_records
Generic equality-filtered read of one TCA table — never raw SQL. Fields are validated against the TCA and credential-like columns are silently dropped; the same table gates as search_records apply.
get_record_history
One record's change history from sys_history, newest first: when, which backend user, which action, and per modification the changed fields as old → new values. Values of credential-like fields are never rendered — only the fact that they changed. Same table gates as read_records, and non-admins additionally need page-show access on the record's page.
resolve_url
Map a URL (or path) of this instance to the page that serves it: site, language, page uid/title/slug and route arguments. Routing only — no request is sent; foreign hosts cannot match by construction. Non-admins need page-show permission on the resolved page.
get_typoscript
The resolved frontend TypoScript (setup or constants) effective on a page, with a dotted path drill-down and capped output. Admin-only — constants routinely carry API keys — and credential-like values render as [redacted] on top of that.
get_tsconfig
The rootline-merged Page TSconfig effective on a page, with the same path drill-down, output cap and redaction as get_typoscript. Admin-only.
get_last_exception
The newest exception/error from the TYPO3 file logs with its parsed stack trace and the surrounding source lines of the project frames inlined. index steps back through older errors, search filters by message, class or component. Admin-only.
read_source
A line-numbered range of one project source file. Paths must resolve inside the project root; dotfiles, var/* (except var/log), config/system, settings.php/additional.php, key material and credential paths are structurally unreadable. Admin-only.
search_code
Literal-substring (or opt-in regex) search across the project's source files, returning path:line hits. Vendor, var and dot directories are never searched; matched credential lines are value-redacted. Admin-only.
probe_url
One GET against a URL of this instance: status, key headers, timing and a short body excerpt — and on a 5xx the matching exception from the TYPO3 logs is appended automatically. Foreign hosts and non-http(s) schemes are denied; redirects are reported, not followed. Admin-only.
get_full_tca
The TCA index: the names and titles of all accessible tables, each with a pointer to get_table_schema. A navigation aid so the model can traverse the schema without the whole (multi-megabyte) TCA being sent at once. The same table gates as get_table_schema apply. Optional filter and extension narrow the list.
get_table_schema
One table's schema in a readable form: control settings plus, per field, its type and — for relational fields — the foreign table and relation kind (the value over get_tca). Sensitive tables are denied for every user; credential-like columns show name and type only.
get_flexform_schema
The data structure of a TCA FlexForm field, rendered as sheets and fields. When the field selects one of several structures by a pointer, the available keys are listed so a follow-up call can pass ds_pointer. Same table gates as get_table_schema.
fluid_resolve
Which physical Fluid file backs a template, partial or layout name in an extension: the candidate paths in override order with an exists flag and the winning path — to debug a wrong or missing template. Paths only. (Resolves an extension's own Resources/Private paths; TypoScript override root paths need a live rendering context and are not reflected.)
validate_tca
Structural TCA checks: ctrl.label/ctrl.type naming undefined columns, foreign_table references to unknown tables, showitem entries referencing undefined columns or palettes. One table or all accessible tables; findings name schema keys, never record data.
check_typoscript
Scans the TypoScript effective on a page (constants and setup) for syntax errors — invalid lines, unbalanced braces, @import matching no file — using the same core scanner as the backend's TypoScript module. Reports source and line number only, never the offending line's content (a constants line may carry an API key). Admin-only.
list_extensions
The installed (active) extensions: key, version, composer name and title — no package paths. Admin-only.
get_site_config
Without arguments the configured sites (identifier, base, root page); with identifier that site's configuration flattened to dotted key: value lines. Credential-like keys (camelCase included, e.g. apiKey) render as [redacted]. Admin-only.
list_scheduler_tasks
The scheduler tasks with next execution, disabled flag and a last-run-failed marker. The serialized task object is never unserialized; degrades gracefully when EXT:scheduler is absent. Admin-only.
get_system_status
One compact block: TYPO3/PHP/database versions, application context, composer mode, OS family, timezone — no paths, no hostnames. Admin-only.
list_deprecations
The newest distinct messages from the deprecation log, deduplicated with a ×count suffix and project paths relativized — the upgrade work list. Admin-only.
list_middlewares
A PSR-15 middleware stack (frontend or backend) in execution order with identifiers and classes. Admin-only.
site_rag_query
Curated evidence about the website's own public content for a question: source id, title, URL and match excerpt per source, retrieved from the best available search index — EXT:solr, ke_search, indexed_search or a database fallback — and labelled with the answering backend (ADR-049). Index-level filtering is always public-only (what the anonymous visitor could read).
site_fetch_source
The full indexed text behind a site_rag_query source id, capped at 8000 characters — for reading a promising source beyond its excerpt.

The writing tools 

Five tools change anything at all: update_page_metadata, set_file_alternative_text, move_content_element, create_content_element_draft and create_translation_draft. All five write through the TYPO3 DataHandler, as the acting backend user, in the live workspace only, on exactly one record per call (ADR-135, ADR-146).

What holds for all of them:

  • They ship disabled, sit in their own editing group, and are not offered until both the group and the tool are enabled.
  • Every call pauses for a human decision (ADR-134). The approval card names the record and the values, together with the values the write would REPLACE (ADR-136); nothing is written until somebody presses Approve, and the approver must themselves be permitted to run the tool (ADR-133).
  • Permissions are enforced twice: the tool checks the acting user's own rights first, and the DataHandler enforces its own rules on top. A non-admin therefore writes only what the backend already lets them write.
  • A field the user lacks the "exclude field" grant for is dropped by the DataHandler without an error. Every one of them re-reads the record afterwards and reports that as a failure rather than as a successful write.
  • A call the tool would refuse is refused whole rather than applied in part, and a record the acting user may not reach is refused with the same words as a record that does not exist — so a refusal never confirms that a uid exists.
update_page_metadata
Sets a fixed set of descriptive fields on one page. Editable: title, subtitle, nav_title, abstract, description, keywords and — when EXT:seo is installed — seo_title, og_title, og_description, twitter_title, twitter_description. Anything else (slug, hidden, doktype, fe_group, perms_*, no_index, the image relations …) is refused. Authorised by the acting user's page-edit right; the DataHandler then enforces tables_modify and the field-level grants.
set_file_alternative_text

Sets the alternative text (sys_file_metadata.alternative) of one managed file, identified by its sys_file uid — the accessibility gap an editor most often leaves behind. It writes that one field and nothing else.

Authorised by the same storage allow-list and file mounts as the read-only FAL tools; core's own file-metadata permission check (a writable file mount) then applies inside the DataHandler, so a read-only mount is refused there.

Two limits worth knowing before enabling it:

  • It never creates a metadata record. A file that carries none is refused, in the same words as a file the user may not reach — so the model cannot tell "not yours" from "not indexed".
  • It writes the live, default-language record only and takes no language argument. A translation, and a draft version of the same record in a workspace, are both left alone. Translated alternative texts stay a backend job (ADR-135).

An empty string is accepted and is the correct value for a decorative image.

move_content_element

Moves one content element to a page and a column. The element keeps its uid, its content, its language, its history and its references — only its place changes, which is what makes it the least committal write in the set.

Both ends are authorised: the acting user needs content-edit rights on the source page as well as on the target, because moving an element out of a page edits that page's content too. An after_content_uid anchor must sit on the target page and in the same language; a wrong anchor is refused rather than silently corrected.

The destination column is always stated explicitly on the wire, so the element lands where the approval card said it would even when the anchor element sits in a column the caller did not expect.

create_content_element_draft

Creates one content element on a page — the first tool that brings a record into being. Three things bound it:

  • It is always hidden. There is no argument to switch that off: publishing is a separate act with a separate audience, and the approval that let the tool run approved a draft.
  • The content type is an allow-list (header, text, textmedia, bullets) intersected with what the installation's TCA actually declares. Types whose payload is configuration rather than prose — list (a plugin), html, shortcut — are out of reach.
  • The field set is fixed: headline, body text, column, language, position. This is not a generic record API.

bodytext reaches the DataHandler and its RTE transformation exactly as an editor's input does. It is bounded in length and not otherwise filtered — an editor may write the same markup by hand, and a tool enforcing a stricter rule than the CMS would be enforcing a rule that does not exist.

create_translation_draft

Translates one page or content element into another language by running core's own localize command, so connected-mode translations, inline children and every localisation hook behave exactly as they do in the backend.

The result is hidden, which is the part core does not do: localize copies the source's visibility, so a translation of a live page would go live the moment it was created.

An existing translation stops the call and is named in the refusal. The overwrite argument is the only way past it: it deletes that translation first — recoverably (deleted = 1) and in sys_log — and the approval card says so on its own line. Whether the target language exists for the record's site is core's check, not a second implementation here.

Registering a tool 

A tool is a PHP class that implements Netresearch\NrLlm\Service\Tool\ToolInterface:

getSpec(): ToolSpec
Returns the declaration the model receives — a name, a description, and a JSON-Schema parameters block. Build it with ToolSpec::function($name, $description, $parameters).
execute(array $arguments): string
Runs the tool with the model-provided arguments and returns a plain string that is fed back into the conversation as a tool turn.
getGroup(): string
The tool's group — a short, stable identifier used to enable or disable whole families of tools at once. Built-ins use content, editing, structure, system, accounts and configuration; third-party tools declare their own group (recommended: the providing extension's key). See Tool groups.

The interface carries #[AutoconfigureTag('nr_llm.tool')], so a class is auto-registered simply by implementing it — no central registration file to edit. ToolRegistry collects every tagged tool through a DI iterator and indexes it by spec name; two tools with the same name is a developer error and fails fast at container build.

When you write a tool, honour the security contract: treat $arguments as attacker-influenced (the model is steerable by injected skill prose), validate and scope every input (cap volumes, scope identifier lookups), and never return secrets — the result leaves the instance.

Managing tools 

The Admin Tools > LLM > Tools module lists every registered tool with its global enable state and lets an admin toggle it. A disabled tool is refused on every run, everywhere — the runtime gate is fail-closed, so a disabled tool can never be offered to the model regardless of a skill's allowed-tools or the per-run selection in the playground. Some built-in tools (for example get_env_raw and get_php_info_raw) ship disabled by default because they return unredacted, secret-bearing output; enable them only deliberately.

The Tools management module listing each built-in tool with an Enabled or Disabled badge and an Enable/Disable toggle

The Tools module — each registered tool with its global enable state and a toggle. The _raw variants show as Disabled, the redacted tools as Enabled; the Default badge marks a tool sitting at its shipped state.

A tool that carries an editor action declaration (ADR-152) reads differently in that list: it shows an icon, its translated name, one sentence written for a human, and the record types it addresses — instead of the wire name and the description written for the language model. All five writing tools declare one, and the wire name stays visible as the technical detail the toggle acts on. A read-only tool is unchanged.

The declaration is presentation only. It does not decide whether a tool writes — that is the tool's declared effect — and it changes nothing about how a call is fenced, approved or audited.

What editors see 

The declaration is what the Editor Action Center (ADR-158) renders. It lives in the editor module Web > AI tasks and appears in two places: as an AI actions catalogue reachable from that module, and as an AI actions entry in the context menu of a record — a page or a content element — which opens the catalogue narrowed to the actions that address that record.

An editor is offered an action only when all of the following hold, and every one of them is an administrator's decision:

  • the writing tool is enabled in this module (all five ship disabled);
  • its group — editing — is enabled, and where the default LLM configuration restricts tool groups, editing is among them;
  • the tool's data class is within the configured provider's trust-zone ceiling;
  • the backend user holds tasks_use and has the module ticked in their group;
  • the backend user may use the default LLM configuration itself — where that configuration restricts Allowed backend groups, the user is in one of them (see Backend user permissions).

That last point is checked again when the action is started, so an editor outside those groups cannot start a run by naming the action directly either.

Starting an action creates an ordinary agent run restricted to that one tool. Because the tool declares a write, the run suspends before it touches anything and the change appears on an approval card with its preview — the editor is redirected straight to that inbox. Nothing is written until someone approves.

The record an action is offered on is the record its arguments name, which is not always the record it writes: Create content element draft is offered on a page, because the page is what it must be told, and the element it creates is the result. Where an action needs something the selected record cannot supply — Move content element needs a target page — the editor names it in the note, and the approval card shows the destination that was resolved from it.

Files have no context-menu entry yet: the file list identifies a file by its combined identifier rather than by uid, so Set alternative text is listed in the catalogue but has no per-record entry point.

Several records at once 

Once a record is selected — that is, when the catalogue was opened from a record's context menu — each action there also offers Run this on several records (ADR-162). The catalogue opened from the module menu has no record and therefore no bulk entry point either; an action needs a subject, and this module picks none. That page takes a list of record numbers from the same table, seeded with the record that was selected, and shows, before anything starts, which of them the action can run on, which are skipped and why, and what the batch is expected to cost in requests, tokens and money.

At most 100 entries of that list are read at all. A longer paste is cut there and the page says so, because everything past the cut would otherwise become a table row and a record number in a message no one can read.

Starting it creates one ordinary run per record. There is no bulk mode: each record gets its own approval card with its own preview, and an approver decides them one at a time. At most 20 records are started in one press, because the runs execute inside the one backend request.

The AI budget is checked once per run, so a batch can run out of budget partway through. When that happens the batch stops and names the records the stop kept from starting — the record it stopped on was run, and is reported separately. Nothing is left half-written: the runs that did start are proposals awaiting approval, not changes.

Runs that ended for some other reason are reported by kind — failed, stopped by a guardrail, cancelled, or simply finished without proposing a change — so a batch in which everything failed does not read like one in which nothing needed changing.

The estimate on that page is deliberately rough and says so: it does not count the system prompt and skills the runtime adds, and its upper price assumes every request returns the configured token ceiling. It shows no price range at all unless the model record carries both an input and an output price and the configuration sets an output ceiling — an absent range means "unknown", which 0.00 would not. Treat it as an order of magnitude, not an invoice.

Tool groups 

Every tool belongs to a group (its getGroup() value). The built-in groups carry a translated name in the module header beside their identifier; a group a third-party extension brings has no translated name and shows its identifier alone. The built-in taxonomy:

Group Tools
content search_records, get_page_content, read_records, get_record_history
structure get_pagetree, get_tca, read_fal_asset_meta, get_full_tca, get_table_schema, get_flexform_schema, resolve_url, validate_tca
system get_env (+ raw), get_php_info (+ raw), fetch_logs, probe_url, list_extensions, list_scheduler_tasks, get_system_status, list_deprecations, list_middlewares
accounts list_be_users (+ raw), list_be_groups
configuration get_typoscript, get_tsconfig, fluid_resolve, check_typoscript, get_site_config
code get_last_exception, read_source, search_code
files list_fal_storages, browse_fal_folder, search_fal_files, get_fal_references, find_missing_files
rag site_rag_query, site_fetch_source
editing update_page_metadata, set_file_alternative_text, move_content_element, create_content_element_draft, create_translation_draft — the only WRITING group

Groups can be switched on three levels, and the result cascades fail-closed — a tool is offered only when every level permits it:

  1. Centrally in the Tools module: each group header carries an Enable/Disable group toggle. A disabled group refuses all of its tools — including same-group tools installed later — and a per-tool override can not re-enable a tool inside a disabled group (predictable, fail-closed). Per-tool toggles keep working but take effect only once the group is enabled again.
  2. Per configuration: the Allowed tool groups field on an LLM configuration restricts agent runs with that configuration to tools of the selected groups (empty = all groups). This intersects with a skill's allowed-tools declaration.
  3. Per run in the playground: the tool checkboxes are grouped, and each group checkbox (de)selects its children.

Third-party extensions declare their own group per tool; the recommended value is the extension key, so an admin can disable an extension's whole tool family with one toggle. The design is recorded in ADR-043.

Network egress policy per group 

Network egress is governed per tool group and is fail-closed (ADR-061). Each group has a declared egress scope; a group with no declaration may make no outbound request:

Scope Meaning
none No outbound network request (the default for every group).
own_site Only the instance's own configured site hosts, resolved through SiteFinder — the exact allow-listing probe_url applies, now lifted to the group boundary.

Only the system group (which carries probe_url, the one built-in that fetches over the network) is granted own_site; every other group is none. There is no "any host" scope, so a newly installed or mis-declared tool group can never egress to an arbitrary target. The diagnostics tools that share the system group (get_env, fetch_logs …) never make a network request, so the grant does not loosen them.

Using the Tool Playground 

The playground lives in Admin Tools > LLM > Playground and is admin-only. It is a sibling of the Tools management module: the playground runs the loop, while the Tools module governs which tools exist and are enabled.

The Tool Playground module with the LLM configuration picker, an empty prompt box, the Run button, and the Available tools panel

The playground shell — the configuration picker, prompt box and the Tools available to this run panel, which lists every registered tool with the default-enabled ones pre-checked and the disabled _raw variants unchecked.

  1. Pick an LLM configuration from the dropdown. Its vault-stored API key, model, temperature and system prompt are what the loop actually runs on — the playground never falls back to a default model.
  2. Type a prompt. Optionally open the override panels to force-inject skills (added on top of the configuration's own), force-add snippets (inserted as leading system messages), override the system prompt, cap the max rounds, or tick capture raw provider response.
  3. Click Run — or Dry run to assemble the prompt and inspect exactly what would be sent without calling the model.
  4. Read the inspector — live from the moment you click Run. A summary strip reports rounds, tool calls, the prompt/completion token split, estimated cost, wall time and status. The step list is the nr_llm ↔ LLM dialog in order: each round opens with a Context budget step, then its outbound request (the messages sent and the tools offered) appears the instant it goes out, a waiting indicator shows while the model works, then the response and each tool execution stream in. Select a step to open its detail — requests carry Messages sent and Tools offered; responses carry Structured, Raw JSON and Thinking. The model's final answer closes the run.
  5. The Context budget step says where the window went for the round that follows it, so you can act on the component that is yours to change rather than only learn that history was dropped. It has two tabs:

    Where the window went
    The window, the output reserve, the safety margin and the resulting budget, then four component lines — transcript, tool schema, system prompt (incl. snippets) and skills — that sum to the estimated total, plus what is left. On this surface the system-prompt line always reads counted in the transcript and the skills line always reads 0: the agent loop builds both into the transcript before the fit measures it, so their content is already on the transcript line. The table says so under the figures. A send whose reserve exceeds the whole window is handed to the provider unmeasured, and the step reports no accounting instead of a window of zero.
    Injected context
    Every snippet and skill this run injects, by name only, with the data class it declared (ADR-144) or not classified, and the strictest class across all of them. The list covers the snippets and skills you force-injected for this one run as well as the configuration's own — the input-context gate itself still answers for the configuration alone, so a forced source is shown here and is not gated.
A completed tool run — the summary strip, the ordered step list and the selected step's detail tabs for a two-iteration agent loop

A completed run — the summary strip (rounds, tool calls, token split, wall time, status), the ordered step list of the nr_llm ↔ LLM dialog, and the selected step's detail: here round 1 requested the list_be_users tool, whose result is fed back so round 2 can answer.

The Tools available to this run list lets you narrow a single run to a subset of the globally-enabled tools (the full list and the global enable/disable controls live in the Tools module). Raw-response capture is off unless you tick it, so ordinary runs never retain the provider's raw payload. Every displayed string — tool arguments, tool results (which may include sys_log content), and the final answer — is rendered escaped; HTML is only ever shown inside a sandboxed preview, never injected into the page.

Each run is bounded by the iteration cap (default 5) and, when the configuration's backend user has a budget, by the per-iteration budget pre-flight. If the cap is hit with tools still pending, a final tool-free completion synthesises a closing answer and the run is marked truncated. The aggregated token usage is reported; the monetary cost is recorded in the usage table by the middleware pipeline.

Ollama model-capability dependency 

Tool calling depends on the model, not just the provider. For Ollama, only function-calling-capable models — for example llama3.1, mistral, qwen2.5 — return tool calls. A model without function-calling support simply answers the prompt directly and never calls a tool; the loop ends gracefully on the first plain answer. If a configured Ollama model never seems to use the available tools, verify it is one of the function-calling models for your Ollama version.

Gating tools with allowed-tools in a skill 

A skill's SKILL.md front-matter may carry an allowed-tools key that gates which tools the skills attached to a configuration (or task) grant for a run. The resolution is fail-closed on declaration, computed over the configuration's effective skills (enabled, non-orphaned, at or above the instance trust floor, deduped):

  • Absent (no skill declares allowed-tools) — no opinion; all registered tools are offered.
  • Declared list — the union of the declared lists across the effective skills; only those tools are offered (intersected with what is actually registered, so an unknown name is dropped).
  • Declared empty (allowed-tools: []) — declares zero tools; if no other effective skill widens the set, the run gets no tools and is a single plain completion.

A disabled or orphaned skill never grants tools. The allow-list is enforced both when the tools are offered to the model and again when a tool call is executed, so a prompt injection cannot reach a tool the skills did not grant.

The effective set is not the same as what ends up in the prompt. The skill-block byte budget (skills.maxBytes, see Managing skills) is applied after the allow-list has been computed, while the block is assembled. A skill dropped for the budget therefore still contributes its allowed-tools, but its usage rules never reach the model. That order is deliberate: a budget-aware union would let the drop of the last declaring skill collapse the allow-list to "no restriction" — every registered tool — which is a looser gate, not a tighter one. Keep skills.maxBytes above the composed size of the skills you rely on if you want their prose to arrive alongside the tools it describes; every drop is logged as a warning.

See ADR-038 for the runtime design and security rationale.

MCP servers 

The MCP Servers module (admin-only) connects agent runs to tools offered by an external Model Context Protocol server — a translation service, a ticket system, any MCP-speaking backend.

How it works 

  1. Configure a server — endpoint, authentication, the class of data its tools may see, and whether its tools need human approval. A server that declares no data class supplies nothing: there is no default anybody silently inherits (fail-closed, ADR-113).
  2. Import its catalogue — an explicit action that fetches the tools the server advertises. Import is the only network call that happens outside an agent run; nothing talks to the server just because a page rendered. Tool input schemas are normalised into the supported subset on import; a tool whose schema cannot be expressed is skipped rather than silently weakened.
  3. Enable individual tools — imported tools start disabled and are switched on one by one, exactly like the builtin tools in the Tools module.

Is the server alive? 

Test connection performs the MCP handshake and reports what came back: how long it took, which protocol revision the server chose and what the server calls itself. The report appears on the server's card and stays there until you leave the page — only the latency is stored, so the rest would be lost to a page reload, and this is the one action in the module that therefore does not reload. It writes no catalogue — no tool is added, removed or orphaned, and the import status and the last import error stay exactly as they were. Use it to check a server before enabling it, and to tell "the server is down" from "the server is fine and this tool is gone".

Each server card also shows Last successful contact. That is the last time this installation completed any round trip against the server — a tool call, an import or a connection test — together with how long that round trip took. It is deliberately not the same as Last import: a server that has been answering tool calls all month can still show an import from six weeks ago, and previously there was no way to see the difference.

A failed connection test replaces the report on the card with its reason, and is stored nowhere. Only a success moves the contact date.

Guard rails 

  • Remote tools always require an administrator, and count as non-idempotent writes — unconditionally, whatever the server's catalogue says about them: they are never replayed on a retry, and never waved through the trust-zone gate in observe mode. That the classification cannot be argued down from the far side is what makes the setting below the only place the question is answered.
  • Requires approval decides whether an agent run stops and waits for a person before it calls any tool of this server. It is on for a newly configured server: what a remote tool actually does cannot be inspected from here, and the server's own annotations do not get to answer the question about themselves (ADR-134). Switch it off per server once you know what its tools do. This holds for every server, including one configured before the setting existed: an update leaves it requiring approval, and nothing switches that off on your behalf.
  • The number of remote calls one run may make is bounded (default: 20) — a remote call crosses the network while a backend user waits, and nothing else limits how many a model asks for at once.

What a remote answer can contain 

An MCP server may answer a tool call with several typed blocks: text, images, embedded resources. This client reads text only. When a server sends anything else, the blocks are dropped and the answer opens with a line saying how many were dropped and of which type — so a model reading a partial answer is told it is partial, and a run whose tool returned only an image is not told the tool returned nothing. The line comes first because a long answer is shortened before the model sees it, and a note at the end would be the part that is cut. If you need the image itself, the tool is not usable from here yet.

A call that fails does not fail the run. It comes back as a failed tool result naming the server, the model is told, and the run carries on. This covers both ways a call fails: the server not answering usefully — it is down, it refuses the credential, it sends something that is not JSON-RPC — and the server answering that the tool itself failed, which is the ordinary case of a missing page or a rejected argument. Both are recorded as failures in the run's event stream, so a server that is flaky is visible without reading transcripts.

See ADR-116 for the design rationale, ADR-154 for what liveness is measured on and why the connection test writes nothing, and ADR-161 for the conformance suite every supported connection is held to — including the one thing it does not do: cancelling a call that is already in flight. Cancelling a run stops it at the next step, but an outstanding remote call still runs to its 15-second timeout.

AI-powered wizards 

The extension includes AI-powered wizards that use your existing LLM providers to generate configurations and tasks automatically. This reduces manual setup to a minimum.

Setup wizard 

The setup wizard guides first-time configuration in five steps:

  1. Connect — enter your provider endpoint and API key.
  2. Verify — test the connection.
  3. Models — fetch available models from the provider API.
  4. Configure — create an initial configuration with system prompt and parameters.
  5. Save — run a test prompt to confirm everything works.
Five-step setup wizard with progress indicator showing Connect, Verify, Models, Configure, and Save steps

The setup wizard walks through provider creation, connection testing, model fetching, configuration, and a test prompt in five steps.

Access it from the Dashboard when no providers are configured, or via the setup wizard link at any time.

Configuration wizard 

The configuration wizard generates a complete LLM configuration using AI. Instead of filling in each field manually, describe your use case in plain language and the wizard generates everything.

  1. Navigate to Admin Tools > LLM > Configurations.
  2. Click Create with AI.
  3. Describe your use case (e.g., "summarize blog posts in three sentences").
  4. The wizard generates: identifier, name, system prompt, temperature, and all other parameters.
  5. Review and click Save.
Configuration wizard form with a plain-language description field and generated configuration preview

The configuration wizard generates all fields from a natural-language description.

Task wizard 

The task wizard creates a complete task setup — a task and a dedicated configuration — in one step.

  1. Navigate to Admin Tools > LLM > Tasks.
  2. Click Create with AI.
  3. Describe the task (e.g., "extract the five most important keywords from an article").
  4. The wizard generates: a task with prompt template, a configuration with system prompt and parameters, and a model recommendation.
  5. Review and click Save.
Task wizard form with description field and generated task preview

The task wizard generates a complete task and configuration from a description.

Model discovery 

On the model edit form, use the Fetch Models button to query the provider API. This auto-populates available models with their capabilities, context length, and pricing metadata.

What the capability checkboxes are seeded with 

Discovery writes only the capabilities the provider's own response states. How much that is differs per provider:

Provider Reported by the API
Mistral chat, tools, vision (per-model capabilities)
OpenRouter chat, tools, vision (supported_parameters, architecture.input_modalities)
Ollama chat, tools, vision, embeddings (/api/show, Ollama 0.6 and newer)
Gemini chat, streaming, embeddings (supportedGenerationMethods); vision and tools come from the built-in table for known model ids
Anthropic chat, vision, tools, streaming for every model the listing returns — it returns Claude chat models only, and they all have them
OpenAI from the built-in table, keyed by model id; an id outside it is seeded from its prefix (dall-e-, tts-, whisper-) and otherwise chat alone
Groq chat only — the listing carries no capability field at all

Where the API reports nothing, the record is seeded with the narrowest true statement rather than a guess. Check the capability checkboxes after discovery and tick what the model actually does: the field is yours to edit, and configurations that select models by criteria match against it.

Per-user AI budgets 

The tx_nrllm_user_budget table caps per-backend-user AI spend independently of the per-configuration daily limits on tx_nrllm_configuration. A user request must clear BOTH layers: any limit on the preset they chose AND any limit on their personal budget record.

What a budget caps 

Each row in tx_nrllm_user_budget binds to exactly one be_user and defines six independent ceilings. 0 on any axis means "unlimited on this axis".

Field Unit Reset cadence
Max Requests/Day count Every day at 00:00 server-local time.
Max Tokens/Day count Every day at 00:00 server-local time.
Max Cost/Day ($) USD Every day at 00:00 server-local time.
Max Requests/Month count First of the month, 00:00 server-local time.
Max Tokens/Month count First of the month, 00:00 server-local time.
Max Cost/Month ($) USD First of the month, 00:00 server-local time.

Usage is aggregated on demand from tx_nrllm_service_usage — the same table the UsageTracker already writes to per request — so there is no second write per request and no way for a separate counter to drift away from the source of truth.

Creating a budget 

Budget records have rootLevel = -1, so admins can create them at the TYPO3 root (pid = 0) or on any regular page. Keeping them at the root is the convention because budgets are site-wide admin concerns, not page-scoped content; the recipe below follows that convention.

  1. Open Web > List in the root (page UID 0) — or on the page where you keep other cross-site configuration records.
  2. Click Create new record.
  3. Choose LLM User Budget.
  4. Pick the backend user, set the ceilings, toggle Enforce this budget on.
  5. Save.

How the check runs 

Before dispatching a request the consuming extension calls NetresearchNrLlmServiceBudgetService::check(). The service:

  1. Returns allowed when the user has no budget record, when Enforce this budget is off, or when every ceiling is 0.
  2. Aggregates today's usage and this month's usage in a single database roundtrip.
  3. Evaluates the daily window first; the monthly window only if the daily window passes.
  4. Adds +1 request and +plannedCost to the usage figures before comparing, so a user at exactly the limit is still allowed one more call.

The returned BudgetCheckResult names which bucket was tripped (exceededLimit as a stable machine key, plus a human-friendly reason string suitable for log output or caller-side wrapping).

Budgets vs. configuration limits 

Both layers persist but cap different things:

Axis Configuration daily limits Per-user budgets
Bound to a preset (tx_nrllm_configuration) a backend user (tx_nrllm_user_budget)
Question answered "Can ANY editor keep using this preset today?" "Can THIS editor keep spending this month?"
Windows daily daily AND monthly
Dimensions requests, tokens, cost requests, tokens, cost
Both must pass yes yes

See ADR-025: Per-User AI Budgets for the full design rationale, including the alternatives (counter table, group-level budgets, auto-throttling) we considered and why they were rejected.

Backend user permissions 

nr_llm is administrator-only by default: every module and every AJAX action denies non-admins, and nothing changes at update time. Access for non-admin users is opened per capability grant (ADR-130), never wholesale.

Assigning grants 

Grants are ordinary TYPO3 custom permission options. In the backend group record (Access Lists tab, section AI (nr_llm) permissions), tick the grants the group should hold. Notes:

  • Grants are group-scoped — a backend user without groups cannot hold a grant.
  • Administrators hold every grant implicitly.
  • Revoking a grant takes effect with the user's next request.
The AI (nr_llm) permissions section in a backend group's Options tab

The two grants in the backend group form (Options tab, collapsible section "AI (nr_llm) permissions"), below the dashboard widget permissions.

Available grants 

Execute AI tasks (tasks_use)

Run existing AI tasks and refresh their input data. What a task may read and which model and configuration it uses is defined by whoever manages the task — that is the trust boundary: task managers define, grant holders execute. Every run is pre-flighted against the user's own usage budget and attributed to them.

Since ADR-158 the same grant opens the Editor Action Center in that module. It does not decide which actions appear there: that is the tool gate's answer plus access to the default LLM configuration — a user outside its Allowed backend groups is offered nothing and can start nothing — and every writing tool is disabled until an administrator enables it. Starting an action always suspends for approval before anything is written.

Approve suspended AI runs (agent_approve)
Approve, deny or answer agent runs suspended for a human decision — including runs started by other users. Without this grant a user can only ever decide their own runs. Deliberately part of no recommended preset: granting it is an explicit trust decision.

Current reach 

Grant holders work in the dedicated AI Tasks module (web group, ADR-131). Reaching it takes BOTH switches: the module must be ticked in the group's module list (access => user) AND the grant must be held — the module switch alone never grants execution. Tasks with the table input type are not offered to editors (their record picker has no read boundary yet and stays admin-only). Everything else — configuration, providers, models, the playground, record browsing — remains administrator-only regardless of grants.

Verifying the specialized services 

Translation, image generation and speech are not configured as records. Each one reads a nr-vault identifier from the Extension Configuration, and until a consuming extension calls it, nothing tells you whether that identifier resolves to a working credential.

The test page — LLM > Overview > Test — answers that directly for translation and image generation.

Which setting belongs to which service 

Setting Used by
translators.deepl.apiKeyIdentifier DeepL translation
image.fal.apiKeyIdentifier FAL image generation
providers.openai.apiKeyIdentifier DALL·E images, Whisper transcription and text-to-speech — all three share this one identifier and cannot be configured separately

Translation 

Enter a text and a target language. Leaving the source language empty makes the service detect it.

The translator picker lists every registered translator and marks the ones without a usable credential. Leaving it on LLM (default) runs the LLM path, which needs a working provider but no specialized key — useful to confirm the page itself works before pointing at DeepL.

Selecting a translator routes to that one, which is the case worth running after entering its vault identifier. The result names the translator that answered, the detected source language, and either the characters billed (specialized translators) or the tokens used (the LLM path).

Image generation 

Enter a prompt and pick OpenAI (DALL·E) or FAL. Each service applies its own default model and size; the result reports which were used, and DALL·E additionally reports the prompt it rewrote yours into.

Reading the result 

A missing or unresolvable credential is reported as such, naming the Extension Configuration — that is the failure this page exists to surface. Any other failure of an otherwise-configured service is reported generically, with the detail in the system log, so that provider responses never reach the browser.

Both endpoints are admin-only and spend real provider quota on every run.

Speech 

Transcription and text-to-speech have no test surface. Transcription needs an audio upload and synthesis returns binary audio, which raises the same storage question the image test declines to answer, with less to gain from answering it. Their credential is the shared OpenAI identifier above, so a successful DALL·E test also confirms the key those two use.

Usage analytics 

The Analytics submodule turns the per-request data in tx_nrllm_service_usage into an at-a-glance view of what your AI spend and usage look like over time — cost and request trends, breakdowns by provider, model, and service, and per-user consumption against this month's budget.

LLM Usage Analytics dashboard — KPI tiles, a cost and request trend chart, breakdowns by provider, model, and service, and a per-user table with monthly budget bars

The Analytics dashboard: KPI summary tiles, the cost/request trend, the provider / model / service breakdowns, and per-user consumption against each user's monthly budget.

Opening the module 

Open Admin Tools > LLM > Analytics. The submodule sits next to the other LLM sections in the left-hand navigation and is admin-only, like the rest of the module.

Choosing a date range 

A range switcher at the top of the page selects the reporting window. The range is a plain ?range= link, so changing it is an ordinary page reload — there is no AJAX. Four presets are available:

Preset Window
7d The last 7 days (today and the six preceding days).
30d The last 30 days. This is the default — any unknown range value falls back to 30d.
90d The last 90 days.
month From the first of the current calendar month to today.

KPI tiles 

A row of tiles summarises the selected range:

  • Total cost — the summed estimated cost across the window.
  • Total requests — the number of AI requests recorded.
  • Total tokens — prompt plus completion tokens consumed.
  • Providers — how many distinct providers were used.
  • Models — how many distinct models were used.

These are totals for the chosen range, not all-time figures.

Cost and requests trend 

A line chart plots daily estimated cost and daily request count across the range. Days with no usage are filled in as zero so the line is continuous rather than skipping gaps.

Breakdown charts 

Three bar charts split the window's usage along different axes:

  • By provider — cost and requests per service_provider (OpenAI, Anthropic, Ollama, …).
  • By model — cost and requests per model. This dimension is new: it relies on the model_uid / model_id columns added to the usage table, so it only reflects usage recorded after that change.
  • By service — cost and requests per service type (chat, vision, translation, speech, image).

Per-user table 

A table lists usage grouped by backend user, ordered by cost. Each row shows the user's request count, token total, and estimated cost for the selected range, plus a monthly-budget bar that visualises how much of their per-user budget (see Per-user AI budgets) they have consumed.

Requests made without an authenticated backend user (CLI, scheduler, be_user = 0) are grouped under a system row.

Fallback rescues 

A table lists the runs a different configuration answered after the requested one failed — each line is one request the configuration you configured did not serve. It shows what was requested and what answered, each with its provider and model, how many configurations were tried, and how long the whole run took.

Unlike the rest of this module the list is read from the telemetry log (tx_nrllm_telemetry), not from the usage table, so it also covers runs that produced no billable usage.

Two things it deliberately does not show:

  • Runs nobody served. A chain that was tried and exhausted names no serving configuration — it is a failure, not a rescue, and appears in the provider health scores instead.
  • Runs recorded before this feature existed. Rows written by an older version carry no serving configuration and are left out rather than guessed at.

At most the 200 newest rescues of the period are listed. The limit counts rescues, not failed attempts, so a long outage — which writes one row per request — cannot push the rescues out of the list.

A configuration appearing here repeatedly is the signal to look at: its calls are being answered by a sibling, which may use a different provider, model and price than the one you selected.

Provider health and circuits 

A table lists every provider that is either configured and active or named by a run in the telemetry window, with its health score, the number of samples the score is based on, the window those samples were taken over, and the state of its circuit breaker.

Health and circuit state are both keyed by adapter type, not by provider record — two provider records on the same adapter share one score and one circuit, because it is the provider that is unhealthy, not the record.

Column Meaning
Score A single 0.00–1.00 number combining success rate and mean latency, success rate weighted four times as heavily (see ADR-063: Provider Resilience — Circuit Breaker, Health, Idempotency). Higher is healthier.
Samples in window How many runs the score was computed from. Read it before the score: 0.90 over two calls and 0.90 over two thousand are different statements.
Success rate Share of runs the provider served itself. A run a fallback rescued counts as a failure of the requested provider.
Avg latency Mean end-to-end time of the self-served runs. A provider whose runs in the window were all rescued by a fallback has no self-served run to measure: the cell says the latency was not measured instead of showing 0 ms. Its score and success rate are real — the first attempt did lose.
Circuit closed (normal), open (failing fast for the cooldown) or half-open (cooldown elapsed, one probe due), plus the current consecutive-failure streak.

Unlike the rest of this module the table ignores the date range selected above. Scores come from a rolling telemetry window (15 minutes by default, named on the page) and circuit state is live cache state — neither can be re-cut to a 90-day report period.

A provider with no telemetry in the window shows no data — not a score of zero. It was not called; that is not the same as failing.

A note on cost 

All cost figures are estimated. They are computed from the model pricing you configured (cents per 1M tokens, applied to the recorded prompt/completion token split), not billed back from the provider. Treat them as a planning and trend signal, not as an invoice. Costs are captured at call time, so they reflect the pricing in effect when each request ran. See ADR-029: Usage Analytics Dashboard for the design rationale.

Specialized services (DALL·E, text-to-speech, Whisper, DeepL) still record their requests and units, but their cost is currently shown as 0 — token-based pricing does not apply to them yet. Streaming responses are not recorded at all, because chunked output has no single terminal token count to price.

Usage columns in the list views 

The Providers, Models, Configurations, and Tasks list views each carry three extra columns — Cost (30d), Requests (30d) and Tokens (30d) — summarising the last 30 days of usage for that row, so you can spot the heavy hitters without leaving the list.

Models list with Cost / Requests / Tokens (30d) columns showing per-model usage and estimated cost

The Models list with the 30-day usage columns. Models with no usage in the window show blank cells; free local models show ~$0.00.

Two attribution notes:

  • The Providers column aggregates by adapter type (the value stored on each usage row), not by individual provider record — two providers that share an adapter therefore show the same figures.
  • The Tasks column relies on per-task tracking: each task execution records its task_uid so usage rolls up to the task that triggered it. Calls made outside a task (direct API/service use) are not attributed to any task row.

Demo data for local development 

To populate the module with something to look at during local development, run the dev-only DDEV command:

ddev seed-usage
Copied!

It generates roughly 90 days of realistic historic usage across providers, models, services, and users so the trend line, breakdown charts, and per-user table all have content. This command is for local DDEV environments only — do not run it against production data.

Agent runs 

An agent run is one execution of the tool-calling agent loop. A run can pause and wait for a human before it continues: to approve a tool call it wants to make, or to supply a piece of typed input it asked for. The Agent Runs module is the inbox where you make those decisions and review runs that have finished.

The admin inbox lives in Admin Tools > LLM > Agent Runs; the same actions are also reachable through the editor module Web > AI Tasks (ADR-131). Visibility is actor-scoped: an administrator or a holder of the Approve suspended AI runs grant sees every run, everyone else only the runs they started. Approving continues the run under its owner's identity; the deciding backend user is recorded for audit. The page works fully with JavaScript off (JavaScript only adds focus and a deny confirmation).

The inbox 

The module shows two lists:

  • Awaiting your decision — runs paused for an approval or for input, each rendered as a card with the controls to resolve it.
  • Recent runs — a read-only table of the most recently finished runs (configuration, status, created, finished, cost when non-zero, and — on the runs you may open — a Timeline link to the run's detail page).

If the store cannot be read, the page shows a warning box rather than a silently empty inbox — an empty list therefore means "nothing waiting", not "load failed".

The run timeline 

Timeline opens one run end to end, read-only. It shows the run's summary — correlation, status and why it ended, configuration, rounds, tokens, cost — and below it a single time-ordered list of everything the run produced:

  • Step — a recorded loop step (a request, a model answer, a tool execution).
  • Provider call — a telemetry row: which provider and model served it, how long it took, whether a cache hit or a fallback was involved, and the error class when it failed. These are joined to the run because since ADR-153 every provider call a run makes carries the run's uuid as its correlation id.
  • Governance — a decision taken during the run: a tool the gate withheld, a guardrail block, an approval requirement, an injected-context refusal.

You can only open your own runs; an administrator can open all of them. The approval grant lets you decide another user's waiting run, but not read its timeline — the Timeline link is therefore offered only on the rows you may open. Reaching a run you may not read directly by URL is indistinguishable from reaching one that does not exist: both send you back to the list.

A run started before this extension version has steps but no provider calls: its calls were traced individually and cannot be attributed retroactively.

Approving a tool call 

A run pauses for approval (status WAITING_FOR_APPROVAL) when the agent wants to call a tool that opts in to human approval, or when a guardrail demands it. The card lists every tool call in the pending turn — the tool name and, in a collapsible Arguments block, the exact arguments the model proposed. A call whose tool is no longer registered is flagged.

One Approve or Deny covers the whole pending turn, not a single call. Denying ends the run.

Providing input 

A run pauses for input (status WAITING_FOR_INPUT) when the agent asks for typed data against a declared schema. The card renders a form with one field per schema property (text, number, integer or checkbox, with the field description shown). Submitting validates and coerces the values against the current schema; invalid input re-renders the form in place, keeping what you typed and pointing at the error, rather than losing the run.

Running queued runs asynchronously 

By default a queued run executes in-process, synchronously, with no setup — suitable for interactive and small workloads. For genuinely asynchronous execution, route the queue message to the Doctrine transport and run a consumer:

// settings.php / additional.php
$GLOBALS['TYPO3_CONF_VARS']['SYS']['messenger']['routing']
    [\Netresearch\NrLlm\Service\Agent\Queue\AgentRunQueuedMessage::class] = 'doctrine';
Copied!
vendor/bin/typo3 messenger:consume doctrine
Copied!

Reclaiming stale runs 

When a run is executed asynchronously, the worker holds a 15-minute lease that it renews at every step. If the worker dies, the run is left RUNNING with a lease nobody renews. The reaper reclaims those runs — it puts them back on the queue, or, after three failed attempts, dead-letters them so they stop occupying the running set:

vendor/bin/typo3 nrllm:agent:reap
Copied!

--limit (default 50) bounds how many stale runs one invocation handles. Schedule it from cron or the scheduler's Execute console commands task. It only concerns asynchronous runs — interactive runs hold no lease — and does nothing useful without a running consumer.

Retention and privacy 

A waiting run stores the transcript it needs to resume — the pending tool calls and the conversation so far — verbatim, so it can pick up exactly where it paused. Unlike the per-step event log, this resumable state is kept in full regardless of the configured privacy level, and is cleared when the run settles to a terminal status.

Nothing is deleted until a purge runs. Finished runs are removed on the privacy.retention.agentRun window; runs still waiting for a decision use the separate, deliberately longer privacy.retention.approval window, so a purge never destroys work an approver has not got to yet. Set the approval window generously if approvers may take days.

See Data retention & purge for the retention settings and the purge command that covers agent runs along with every other content-bearing table.

Data retention & purge 

The extension writes several kinds of row that can carry request content: conversation transcripts, agent-run event payloads, evaluation output, the skill audit trail and provider telemetry. All of them are governed by one central privacy policy (ADR-064): what is stored at all, and for how long.

What is stored 

The extension configuration setting Content Privacy Level (privacy.level) decides how much request content is persisted:

none / metadata (default)
Content payloads are dropped. Metadata — timings, token counts, cost, tool names, sizes, error class — is kept. Agent-run steps are stored in this reduced form too, so persistence does not quietly build a prompt archive.
redacted
Content is stored with obvious credentials and email addresses masked and the length capped. A heuristic, not a guaranteed PII scrubber.
full
Content is stored verbatim. Choose this deliberately — for an agent run it means the whole transcript, including tool arguments and results.

Conversation messages are the exception: their content is the feature. A session replays its own history on every turn, so the transcript is always stored — and therefore governed by retention rather than by the level.

How long it is kept 

privacy.retentionDays (default 30) is the window for every category. Each category can override it under privacy.retention.* — set 0 to keep the default:

Setting Covers
privacy.retention.conversation Conversation sessions and their message transcripts
privacy.retention.agentRun Finished agent runs and their event payloads
privacy.retention.approval Agent runs that never reached a terminal status, above all runs suspended for a human approval
privacy.retention.telemetry Provider pipeline metadata (no prompts, no responses)
privacy.retention.evaluation Graded evaluation output
privacy.retention.skillAudit Skill scan findings
privacy.retention.governance Tool-gate denials and guardrail blocks (no prompts, no responses)

A zero, negative or non-numeric value never means "delete immediately" — it means "no override". When to set one at all, and how to read the window that is in force, is covered under The seven retention overrides.

Runs awaiting a decision are deliberately separate. A run suspended for an approval carries the state needed to resume it, so it is only deleted on the approval window. Give it a longer value than agentRun if approvers may take days.

Running the purge 

Nothing is deleted until a purge runs. Schedule the central command — it covers every content-bearing table in one pass:

vendor/bin/typo3 nrllm:privacy:purge
Copied!

It reports the window applied and the rows deleted per category. --days=N overrides every category at once, which is useful for a one-off cleanup:

vendor/bin/typo3 nrllm:privacy:purge --days=7
Copied!

Two single-table variants exist for operators who want separate schedules. They read the same policy, so they cannot drift from it:

vendor/bin/typo3 nrllm:session:purge
vendor/bin/typo3 nrllm:telemetry:purge
Copied!

All three appear in the scheduler's Execute console commands task — no extra registration is needed.

Effective policy 

Four extension configuration keys decide what an installation allows: how much request content it stores, how long it keeps it, whether the tool gate removes an over-ceiling tool or only records it, and which skills may reach a prompt. They sit in three different sections of the Install Tool and are read by three different services at runtime. The Governance tab puts the values in force on one page.

Where the values are shown 

Admin Tools > LLM > Overview, docheader tab Governance (admin-only, like every other nr_llm admin surface). The table has three columns: the setting, the value the runtime applies right now, and the class that resolved it.

Two properties matter when reading it:

  • The value is what happens, not what is stored. Each row is read through the same resolver the runtime uses, so the tab cannot drift from behaviour. A mistyped tools.dataClassEnforcement reads enforce, because that is what the gate applies to the next run.
  • A row that cannot be answered reads unknown. It is never filled in from a shipped default. On a working installation every row is answered; the guarantee exists so a value shown here is always one the runtime would apply.

The tab changes nothing. All four keys are instance-wide and are set in Settings > Extension Configuration > nr_llm (why).

The four keys 

privacy.level (default metadata)
How much request content is written to the log tables — none and metadata drop the payload, redacted masks and caps it, full stores it verbatim. Read by Service\Privacy\PrivacyPolicy, which every content sink asks before it writes. An unrecognised or unreadable value resolves to metadata: the installation stores less than you asked for, never more. See What is stored.
privacy.retentionDays (default 30)
The window after which nrllm:privacy:purge deletes a row. Read by the same PrivacyPolicy. Empty, zero, negative or non-numeric falls back to 30 days — a window of 0 never means "delete immediately". Nothing is deleted until the purge command actually runs (Running the purge).
tools.dataClassEnforcement (default enforce)
Whether a tool whose data class exceeds the trust zone of the provider a run can reach is removed from that run (enforce) or still offered and merely recorded (observe). Read by Service\Tool\DataClassEnforcementResolver — the same object the gate itself asks. Only a literal observe observes; leading and trailing whitespace and letter case are ignored, everything else enforces (ADR-113).
skills.minTrustLevel (default untrusted)
The publisher-trust floor a skill's source must meet before the skill is injected into a prompt or may grant tools: untrusted, community, verified or first_party. Read by Service\Skill\SkillComposerFactory, which builds every skill composer. A missing, unreadable or unrecognised value resolves to untrusted — the lowest floor, at which every enabled skill passes. See Isolation controls: trust, fingerprint, injection scan, audit.

Two keys, opposite fallbacks 

tools.dataClassEnforcement and skills.minTrustLevel react to a broken value in opposite directions, and the difference is deliberate:

Key On a broken value Effect
tools.dataClassEnforcement enforce Strictest
skills.minTrustLevel untrusted Most permissive

Both fall towards the outcome that cannot cause damage, but "safe" points the other way in each case. A broken enforcement value must not switch a security control off, so it enforces. A broken trust value must not raise a bar nobody set, because that would silently hide working skills from prompts with no error anywhere — so it drops to the floor at which nothing is hidden. Raising enforcement never grants a tool; raising the trust floor only ever removes skills.

For an operator this has one practical consequence: do not infer one key from the other. A typo in either key leaves the tab showing a plausible value, but a different one in each case:

  • The tab reads enforce although you set observe — the stored value is mistyped, and the gate is removing tools.
  • The tab reads untrusted although you set verified — the stored value is mistyped, and every enabled skill is passing.

In both cases the tab is right and the Install Tool field is wrong. Fix the field; the tab follows on the next request.

The seven retention overrides 

privacy.retentionDays has seven per-category overrides (privacy.retention.conversation, .agentRun, .approval, .telemetry, .evaluation, .skillAudit, .governance). They are not on the tab: all seven ship as 0, which means "no override", so on an untouched installation eleven rows would repeat the global window eight times.

0 never means "delete immediately" — neither does a negative or non-numeric value. Each category simply uses privacy.retentionDays until you give it a window of its own.

Set an override when one category genuinely needs a different window:

  • Conversation transcripts are the most sensitive rows the extension stores, and unlike everything else their content is kept regardless of privacy.level. A shorter window here is the usual first override.
  • Runs awaiting a decision (approval) need a longer window than finished runs. A run suspended for a human approval carries the state needed to resume it; purging it destroys work in flight. Give it more days than approvers realistically need.
  • Telemetry and governance events carry no prompts or responses — only counts, reasons and the acting user. Keeping them longer than content rows buys capacity trends and denial evidence at no content risk.
  • Skill audit is the append-only provenance trail for everything that reached a prompt. Keep it for as long as you may have to reconstruct that.

The full table of what each override covers is in How long it is kept.

Telling whether it works 

The gate records every denial — and every observe-mode flag — as a governance event carrying the tool name, the reason, the trust zone and the ceiling. Two places show them:

  • The Tool denials by reason dashboard widget. The Trust zone ceiling bar is the data-class axis. It counts a rolling 30 days and has no mode filter: an observe-mode flag and a real removal are the same row, with the same reason. Only the event's detail column separates them (observedOnly=1 marks a flag), and no view reads that column.
  • The Governance blocks widget for the wider picture, including guardrail blocks and approvals.

The bar therefore answers "how often did the data-class axis fire in the last 30 days", not "how many tools would enforcement remove". Use it to see that the axis fires. Take the number you act on from the table.

The list you actually need is one row per configuration and tool, and it comes from tx_nrllm_governance_event:

What the data-class axis did, per configuration and tool
SELECT configuration_identifier, tool_name, COUNT(*) AS events
FROM tx_nrllm_governance_event
WHERE decision = 'tool_denied'
  AND reason = 'trustZone'
  AND detail LIKE '%observedOnly=1%'
  AND crdate >= 1767225600
GROUP BY configuration_identifier, tool_name;
Copied!

observedOnly=1 gives you what enforcement would remove; observedOnly=0 gives you what it did remove. crdate is Unix time — set it to the moment the period you care about started, the switch to observe or the upgrade.

Each row names one configuration and one tool the axis acted on, and that pairing is what you fix. Three ways to change it: raise the trust zone on the provider record, remove an external fallback that drags the configuration's reachable zone down to the external ceiling, or drop the tool from the configuration's allowed groups.

So moving a long-running installation to enforce goes like this: note the time, set observe, let a representative workload run, run the query for the observation window, fix the configurations it names, set enforce again. The bar is at its least trustworthy during exactly this procedure — while you observe it still carries the enforce-mode removals of the preceding 30 days, and after you switch back it carries the observe flags for another 30. The query is unaffected, because it filters on both the flag and the window.

Governance events are purged on privacy.retention.governance — make sure that window is longer than your observation period, or the evidence is gone before you read it.

Would this be allowed? 

Pick a configuration, a tool and — optionally — a backend user, then press Simulate. The tab runs that call past the four gates listed below and reports one verdict plus each gate's own answer (ADR-157).

The verdict is one of three:

Allowed
All four gates permit the call and it would run unattended.
Allowed, after a human approves
All four gates permit the call, and the tool is approval-bound (ADR-134): the run suspends and waits for a decision before it executes. Folding this into Allowed would hide the axis at exactly the moment it decides, so it is its own outcome.
Blocked
At least one of the four refuses. The table says which.

Four gates are asked, each through the service the runtime itself calls:

Gate What it decides Depends on the actor?
Tool gate (ADR-094) registered, enabled, permitted, within the configuration's tool groups, within the provider trust zone's data-class ceiling Yes — through the tool's requiresAdmin()
Input-context gate (ADR-144) whether the snippets and skills this configuration injects may reach the trust zone it can send to No
Routing (ADR-142) whether any model resolves for a tool-calling run at all No
Human approval (ADR-134) whether the tool is bound to an operator decision No

Only one axis is actor-scoped, and the table says so. Routing reads the model catalogue with enable-fields ignored and no user context, the input-context gate compares a configuration against a trust zone, and the approval requirement is a property of the tool's own declaration. A picker that implied four per-user answers where there is one would be worse than no picker.

Three things that can stop a real call are not asked here, so Allowed does not promise them.

Configuration access (ADR-070) is the one the picker makes easy to miss. ConfigurationResolver refuses a configuration whose backend groups the acting user is not a member of. The configuration selector lists every active configuration and applies no such filter. So a group-restricted configuration paired with a non-member reads Allowed on this tab and is refused at runtime. It is the second axis that reads the user's groups, and it is the one the tab does not ask.

The other two are the budget check and the guardrail pipeline. Both decide on the call itself — the remaining spend, the text of the prompt — and a picker supplies neither.

The actor picker is not impersonation. The selected backend user is resolved read-only through the same seam a queue worker uses to authorise for the user who queued its work (ADR-083): the uid is looked up, the fresh database record supplies the permission surface, and the gates are asked. No session is switched, nothing executes as that user, and nothing is written. Privilege comes from the record, so the picker cannot grant rights the account does not have — and a uid that no longer resolves, because the account was deleted or disabled, produces a stated refusal rather than a silent fall back to your own rights.

A simulation is not recorded. The runtime writes a governance event when it blocks a call; a simulation blocks nothing, so writing one would put rows into the audit for calls that never happened. The trade is deliberate and it has a cost: "who checked what, and when" cannot be answered from the audit. See ADR-157.

Observe mode is visible on both gates. A configuration the input-context gate refuses while tools.dataClassEnforcement is observe is reported as permitted and refused: the send proceeds and the refusal is recorded. Reading only "no exception" would have called that allowed.

Why this model? 

The same tab answers the other question an operator asks about a configuration: which model would actually serve a call through it, and why not one of the others. Pick a configuration, optionally the operation the call runs, optionally a policy mode to try, and press Explain (ADR-148).

The answer comes from Service\Routing\RoutingDecisionService — the decision point the runtime itself uses, not a second implementation of the ranking. It reports:

  • the selected model, and the eligible candidates in the order they were ranked, each with its score and the per-signal values behind it;
  • every refused candidate with the reason it was refused — a missing capability, an excluded adapter type, a context window below the minimum, a cost above the ceiling, or a declared capability set without the one the operation needs;
  • the effective policy mode, and whether the operation-capability axis is enforcing or only observing.

Three things are worth knowing before reading it:

A fixed-mode configuration is not a decision. If the configuration names its model, nothing is chosen at call time. The tab says so instead of presenting the named model as the winner of a one-candidate ranking — there are no criteria to debug in that case.

A signal without data is not a zero. no data means nothing was measured for that model. It neither promotes nor demotes: the score is the weighted mean over the signals that do have data (ADR-142). In Provider priority mode no signal is collected at all, and the ordering falls through to provider priority and the established tiebreaks.

Trying a policy mode changes nothing. The mode selector evaluates a hypothetical for that one page view. routing.policyMode in the Install Tool is not written and not affected — the same read-only rule the rest of the tab follows.

Only operations that actually constrain the decision are offered. The others map to no required capability, so they would add nothing to the answer. Leaving the selector on No operation is answered as exactly that — the axis was not applied — and not as an operation that requires nothing.

An empty result is reported in two distinguishable ways, because they need opposite fixes: No candidates at all means the catalogue holds no active model, while a populated Refused, and why table means the criteria and the model records disagree.

Calls that were routed 

The readout above answers a hypothetical. Calls that were routed answers the same question about calls that already ran: the last seven days of runs whose model was chosen automatically, newest first, twenty at a time (ADR-156).

Each row names when the call ran and against which configuration, which model answered it, and the decision behind that: the policy mode, how many candidates were considered, which measured signals actually moved the ranking, and the distinct reasons that refused the rest.

Fixed-mode calls are absent, and that is the point. Nothing was chosen for them, so there is no decision to show. If the table is empty on a busy installation, the likely reasons are that every configuration names a fixed model, or that telemetry.enabled is off in the Install Tool.

"Signals used" means the signal moved this decision, not that the mode weighs it and not that the ranking collected it. A quality decision over a catalogue nobody has scored shows no signals used and ranks exactly as Provider priority would — the weights only apply to signals that have data. A signal the mode weighs at zero is not listed either: quality weighs cost at zero, so Prefer Lowest Cost on a quality configuration shows no cost signal, even though it still breaks ties between models that scored equally.

The candidate models are not stored per call. Which models exist and which lost is a catalogue question; read it off the live catalogue with the readout above. The row keeps the count and the reason set, which is what varies from request to request.

Rows are purged with the rest of the telemetry table by nrllm:telemetry:purge; a window shorter than your observation period deletes the evidence before you read it.

The complexity columns are observed, not applied 

The same rows carry a measurement of how involved each request was: a 0-100 structural score, the request shape (a single question, a conversation, or a tool-assisted transcript), the number of tool schemas on the wire, the payload size in bytes, the token estimate and how much of the model's context window it filled.

You see them for routed calls only. The measurement is taken on every configuration-driven send, fixed-mode ones included, but it is stored on the telemetry row and the table above shows only rows whose model was chosen automatically. An installation with no criteria-mode configuration collects these columns and displays none of them; the figures are in tx_nrllm_telemetry if you query it directly.

Nothing routes on any of it. There is no setting that turns it into a routing signal, and none is planned until three things have been shown on real traffic: that cheaper models hold for simple requests, that quality does not degrade, and that real cost drops by enough to be worth a permanent branch in the decision path (ADR-156 states the criteria in full). The columns exist so that question can be settled with data rather than opinion.

Two readings need care:

The score is uncalibrated. It is three capped terms — conversation turns, tool count, context utilisation — chosen to be defensible, not fitted to anything. Correlate against it; do not treat it as a threshold.

"window not measured" is not "empty". The token and utilisation figures come from the context fit (ADR-143). Where no fit ran they are stored as NULL, and the page says so rather than showing a zero nobody measured. The byte count is unaffected — it needs no fit — so a row that says "window not measured" still tells you how large the send was. A utilisation above 100 % is real: it is the overflow case, and it is deliberately not clamped.

A measured 0 % is a measurement. A short chat against a large window rounds to zero, and the page shows ~N tokens, 0% of the window for it rather than falling back to "not measured".

"complexity not measured" replaces the whole cell, and is a different statement from "window not measured". Some calls choose a model without ever sending a measurable payload through the context fit — an embeddings configuration in criteria mode is the usual one. Its row has a decision to show and nothing to measure, so the score, the shape, the tool count and the byte count are absent rather than shown as zeros.

Why there is no apply button 

The page is read-only on purpose (ADR-140), not unfinished. TYPO3 offers exactly one API for writing extension configuration, and it is marked internal, writes the whole merged array back at once rather than a single key, and is explicitly documented as unreliable when additional.php overrides a setting. An apply button would therefore report success while the next request still served the old value — the worst thing a governance page can do. It would also materialise every shipped default as an explicitly stored value, and the upgrade wizards read that distinction to tell "the operator chose this" from "nobody ever set it". The core synchronisation already erases it on its own the first time an admin enters the Install Tool, so the apply button would not cause that loss — it would make it unconditional.

The Install Tool owns the write, the synchronisation and the cache flush. The tab reports what is in force.

Provider fields 

Providers represent API connections with credentials.

LLM providers list with connection status

Provider list showing adapter type, endpoint, API key status, and action buttons.

Required 

identifier

identifier
Type
string
Required

true

Unique slug for programmatic access (e.g., openai-prod, ollama-local).

name

name
Type
string
Required

true

Display name shown in the backend.

adapter_type

adapter_type
Type
string
Required

true

The protocol to use:

  • openai — OpenAI API
  • anthropic — Anthropic Claude API
  • gemini — Google Gemini API
  • ollama — Local Ollama instance
  • openrouter — OpenRouter multi-model API
  • mistral — Mistral AI API
  • groq — Groq inference API
  • azure_openai — Azure OpenAI Service
  • custom — OpenAI-compatible endpoint

api_key

api_key
Type
string

API key for authentication. Stored as a nr-vault UUID identifier (envelope encryption). nr-llm never stores raw API keys in the database. Required for cloud providers (OpenAI, Claude, Gemini, etc.); not required for local providers like Ollama.

Optional 

endpoint_url

endpoint_url
Type
string
Default
(adapter default)

Custom API endpoint URL.

organization_id

organization_id
Type
string
Default
(empty)

Organization ID (OpenAI, Azure). Sent as the OpenAI-Organization request header by the OpenAI-compatible adapters (openai, azure_openai, together, fireworks, perplexity, custom).

timeout

timeout
Type
integer
Default
120

Maximum time in seconds to wait for the complete API response. A configuration- or model-level timeout overrides this value per request.

max_retries

max_retries
Type
integer
Default
3

Number of retries after the first failed request (0 = try once, no retries). Timed-out requests are not retried.

options

options
Type
JSON
Default
{}

Additional adapter-specific options. Supported key:

customHeaders

Object mapping header names to values, sent on every API request (including streaming), for example:

{"customHeaders": {"X-Custom-Header": "value"}}
Copied!

Custom headers are applied after the adapter's own headers, so a colliding name overrides the adapter value — a mistyped Content-Type breaks that provider's calls.

Model fields 

Models represent specific LLM models available through a provider.

Model list showing capabilities and pricing

Model list with capability badges, context length, and cost columns.

Required 

identifier (model)

identifier (model)
Type
string
Required

true

Unique slug (e.g., gpt-5, claude-sonnet).

name (model)

name (model)
Type
string
Required

true

Display name (e.g., GPT-5 (128K)).

provider

provider
Type
reference
Required

true

Reference to the parent provider.

model_id

model_id
Type
string
Required

true

The API model identifier as the provider expects it (e.g., gpt-5.3-instant, claude-sonnet-4-6, gemini-3-flash).

Optional 

context_length

context_length
Type
integer
Default
(provider default)

Maximum context window in tokens.

max_output_tokens

max_output_tokens
Type
integer
Default
0 (unknown)

Maximum output tokens. Acts as the default output cap for requests whose configuration sets confval-config-max-tokens to 0 and whose caller sent no per-call max_tokens option.

dimensions

dimensions
Type
integer
Default
0 (unknown)

Embedding vector dimensionality. Acts as the default vector size for embedding requests whose options left dimensions unset.

capabilities

capabilities
Type
string (CSV)
Default
chat

Comma-separated capabilities: chat, completion, embeddings, vision, streaming, tools.

cost_input

cost_input
Type
integer
Default
0

Cost per 1M input tokens in cents.

cost_output

cost_output
Type
integer
Default
0

Cost per 1M output tokens in cents.

is_default

is_default
Type
boolean
Default
false

Mark as default model for this provider.

Configuration field reference 

Configurations define use-case presets with model selection and parameters.

Configuration list with model assignments

Configuration list showing linked model, use-case type, and parameters.

Required 

identifier (config)

identifier (config)
Type
string
Required

true

Unique slug (e.g., blog-summarizer).

name (config)

name (config)
Type
string
Required

true

Display name (e.g., Blog Post Summarizer).

model

model
Type
reference
Required

true

Reference to the model to use.

system_prompt

system_prompt
Type
text
Required

true

System message that sets the AI's behavior.

Optional 

temperature

temperature
Type
float
Default
0.7

Creativity (0.0 = deterministic, 2.0 = creative).

max_tokens (config)

max_tokens (config)
Type
integer
Default
1000

Maximum response length in tokens. Set 0 to inherit the model's confval-model-max-output-tokens. Precedence: per-call option > configuration value (> 0) > model max_output_tokens (> 0) > provider default.

top_p

top_p
Type
float
Default
1.0

Nucleus sampling (0.0-1.0).

frequency_penalty

frequency_penalty
Type
float
Default
0.0

Reduces word repetition (-2.0 to 2.0).

presence_penalty

presence_penalty
Type
float
Default
0.0

Encourages topic diversity (-2.0 to 2.0).

use_case_type

use_case_type
Type
string
Default
chat

Task type: chat, completion, embedding, translation.

fallback_chain

fallback_chain
Type
JSON (text column)
Default
(empty)

JSON object with a single key, configurationIdentifiers, whose value is the ordered list of other configuration identifiers to retry against when the primary fails with a retryable error (connection error, HTTP 5xx, or HTTP 429 rate-limit). Non-retryable errors bubble up unchanged. Streaming requests do not trigger fallback — chunks cannot be replayed against a different provider.

Example payload:

{"configurationIdentifiers": ["claude-sonnet", "ollama-local"]}
Copied!

Identifiers are matched case-insensitively; leave empty to disable fallback. See Fallback chain.

system_prompt_data_class

system_prompt_data_class
Type
select
Default
(empty — undeclared)

The sensitivity ceiling for confval-config-system-prompt. Declaring one refuses any send whose serving model sits in a weaker trust zone than the class allows, the same axis snippets and skills carry (ADR-144, ADR-155).

Empty means no statement was made and constrains nothing, so existing configurations behave exactly as before. The class describes the text: a configuration whose system prompt is blank declares nothing whatever this field says.

Enforcement follows the instance-wide tools.dataClassEnforcement switch — see The four keys.

Task fields 

Tasks combine a configuration with a user prompt template for one-shot AI operations.

Task list page

Task list with assigned configurations.

Each task references an LLM configuration and adds a user prompt template. The same configuration can power multiple tasks with different prompts.

Settings 

Provider configuration 

Providers, models and configurations are database-backed and managed in the LLM backend module — not via TypoScript. nr-llm does not read plugin.tx_nrllm TypoScript settings; any such constants/setup have no effect (this is true for both classic TypoScript templates and site sets).

To make the generic chat() / complete() entry points work without pinning a provider per call, set up a default configuration:

  1. Open the LLM backend module.
  2. Create a Provider (e.g. OpenAI) and store its API key as an nr-vault identifier — see API key protection.
  3. Create a Model for that provider.
  4. Create a Configuration bundling the model, then mark it active and default.

The Setup Wizard in the module walks through these steps.

Without an active default configuration, generic calls throw "No provider specified and no default provider configured".

Environment variables 

.env
# TYPO3 encryption key (used for API key encryption)
TYPO3_CONF_VARS__SYS__encryptionKey=your-key
Copied!

Security 

API key protection 

  1. Encrypted storage — API keys are stored as vault identifiers (UUIDs) via the nr-vault extension, which uses envelope encryption. nr-llm never stores raw API keys.
  2. Database security — the database only contains vault UUIDs, not secrets. Ensure backups are encrypted regardless.
  3. Backend access — restrict the LLM module to authorized administrators.
  4. Key rotation — re-encrypt via nr-vault's key rotation mechanism.

Input sanitization 

Sanitize user input before sending to providers:

Example: Sanitizing user input
// Strip markup and control characters from free-text input before it is
// sent to a provider. (GeneralUtility::removeXSS() was removed from the
// TYPO3 core and must not be used.)
$sanitizedInput = trim(strip_tags($userInput));

$response = $adapter->chatCompletion([
    ['role' => 'user', 'content' => $sanitizedInput],
]);
Copied!

Output handling 

Treat LLM responses as untrusted content:

Example: Escaping output
$response = $adapter->chatCompletion([
    ['role' => 'user', 'content' => $prompt],
]);

$safeOutput = htmlspecialchars(
    $response->content, ENT_QUOTES, 'UTF-8'
);
Copied!

Logging 

config/system/additional.php
use Psr\Log\LogLevel;
use TYPO3\CMS\Core\Log\Writer\FileWriter;

$GLOBALS['TYPO3_CONF_VARS']['LOG']
    ['Netresearch']['NrLlm'] = [
    'writerConfiguration' => [
        LogLevel::DEBUG => [
            FileWriter::class => [
                'logFileInfix' => 'nr_llm',
            ],
        ],
    ],
];
Copied!

Log files: var/log/typo3_nr_llm_*.log

Caching 

The extension uses TYPO3's caching framework with cache identifier nrllm_responses.

No cache backend is specified — TYPO3 automatically uses the instance's default cache backend. If your instance has Redis, Valkey, or Memcached configured, nr-llm uses it transparently with zero configuration.

  • Cache identifier: nrllm_responses
  • Cache group: nrllm
  • Default TTL: 3600 seconds (1 hour)
  • Embeddings TTL: 86400 seconds (24 hours)

To override the backend for this cache specifically:

config/system/additional.php
use TYPO3\CMS\Core\Cache\Backend\RedisBackend;

$GLOBALS['TYPO3_CONF_VARS']['SYS']['caching']
    ['cacheConfigurations']['nrllm_responses']
    ['backend'] = RedisBackend::class;
Copied!

Clear cache:

vendor/bin/typo3 cache:flush --group=nrllm
Copied!

Developer guide 

This guide covers technical details for developers integrating the LLM extension into their TYPO3 projects.

Core concepts 

Architecture overview 

The extension follows a layered architecture:

  1. Providers - Handle direct API communication.
  2. LlmServiceManager - Orchestrates providers and provides unified API.
  3. Feature services - High-level services for specific tasks.
  4. Domain models - Response objects and value types.
Architecture overview
┌─────────────────────────────────────────┐
│         Your Application Code           │
└────────────────┬────────────────────────┘
                 │
┌────────────────▼────────────────────────┐
│         Feature Services                │
│  (Completion, Embedding, Vision, etc.)  │
└────────────────┬────────────────────────┘
                 │
┌────────────────▼────────────────────────┐
│         LlmServiceManager               │
│    (Provider selection & routing)       │
└────────────────┬────────────────────────┘
                 │
┌────────────────▼────────────────────────┐
│           Providers                     │
│    (OpenAI, Claude, Gemini, etc.)       │
└─────────────────────────────────────────┘
Copied!

Dependency injection 

All services are available via dependency injection:

Example: Injecting LLM services
use Netresearch\NrLlm\Service\LlmServiceManager;
use Netresearch\NrLlm\Service\Feature\CompletionService;
use Netresearch\NrLlm\Service\Feature\EmbeddingService;
use Netresearch\NrLlm\Service\Feature\VisionService;
use Netresearch\NrLlm\Service\Feature\TranslationService;

class MyController
{
    public function __construct(
        private readonly LlmServiceManager $llmManager,
        private readonly CompletionService $completionService,
        private readonly EmbeddingService $embeddingService,
        private readonly VisionService $visionService,
        private readonly TranslationService $translationService,
    ) {}
}
Copied!

Using LlmServiceManager 

Basic chat 

Example: Basic chat request
$messages = [
    ['role' => 'system', 'content' => 'You are a helpful assistant.'],
    ['role' => 'user', 'content' => 'What is TYPO3?'],
];

$response = $this->llmManager->chat($messages);

// Response properties
$content = $response->content;           // string
$model = $response->model;               // string
$finishReason = $response->finishReason; // string
$usage = $response->usage;               // UsageStatistics
Copied!

Chat with options 

Example: Chat with configuration options
use Netresearch\NrLlm\Service\Option\ChatOptions;

// Using ChatOptions object
$options = ChatOptions::creative()
    ->withMaxTokens(2000)
    ->withSystemPrompt('You are a creative writer.');

$response = $this->llmManager->chat($messages, $options);

// Or using array
$response = $this->llmManager->chat($messages, [
    'provider' => 'claude',
    'model' => 'claude-sonnet-4-6',
    'temperature' => 1.2,
    'max_tokens' => 2000,
]);
Copied!

Simple completion 

Example: Quick completion from a prompt
$response = $this->llmManager->complete('Explain recursion in programming');
Copied!

Embeddings 

Example: Generating embeddings
// Single text
$response = $this->llmManager->embed('Hello, world!');
$vector = $response->getVector(); // array<float>

// Multiple texts
$response = $this->llmManager->embed(['Text 1', 'Text 2', 'Text 3']);
$vectors = $response->embeddings; // array<array<float>>
Copied!

Response objects 

See the API reference for the complete response object documentation. Key classes:

  • CompletionResponse — content, model, usage, finishReason, toolCalls
  • EmbeddingResponse — embeddings, model, usage
  • UsageStatistics — promptTokens, completionTokens, totalTokens

Error handling 

The extension throws specific exceptions:

Example: Error handling
use Netresearch\NrLlm\Provider\Exception\ProviderException;
use Netresearch\NrLlm\Provider\Exception\ProviderConfigurationException;
use Netresearch\NrLlm\Provider\Exception\ProviderConnectionException;
use Netresearch\NrLlm\Provider\Exception\ProviderResponseException;
use Netresearch\NrLlm\Provider\Exception\UnsupportedFeatureException;
use Netresearch\NrLlm\Exception\InvalidArgumentException;

try {
    $response = $this->llmManager->chat($messages);
} catch (ProviderConfigurationException $e) {
    // Invalid or missing provider configuration
} catch (ProviderConnectionException $e) {
    // Connection to provider failed
} catch (ProviderResponseException $e) {
    // Provider returned an error response
} catch (UnsupportedFeatureException $e) {
    // Requested feature not supported by provider
} catch (ProviderException $e) {
    // General provider error
} catch (InvalidArgumentException $e) {
    // Invalid parameters
}
Copied!

Events 

Best practices 

  1. Use feature services for common tasks instead of raw LlmServiceManager.
  2. Enable caching for deterministic operations like embeddings.
  3. Handle errors gracefully with proper try-catch blocks.
  4. Sanitize input before sending to LLM providers.
  5. Validate output and treat LLM responses as untrusted.
  6. Use streaming for long responses to improve UX.
  7. Set reasonable timeouts based on expected response times.
  8. Monitor usage to control costs and prevent abuse.

Streaming support 

Streaming allows you to receive LLM responses incrementally as they are generated, rather than waiting for the complete response. This improves perceived performance for long responses.

Streaming: the prompt is screened, the model and adapter are resolved, the budget is checked, the dispatcher opens the provider stream with fallback, and each chunk passes a sliding redaction window before the Generator yields it to the caller.

Request path down the left, chunk path back up the right. Redaction happens per chunk through a sliding window, so a secret split across two chunks is still caught without buffering the whole response.

Usage 

Example: Streaming chat responses
$stream = $this->llmManager->streamChat($messages);

foreach ($stream as $chunk) {
    echo $chunk;
    ob_flush();
    flush();
}
Copied!

The streamChat method returns a Generator that yields string chunks as the provider generates them. Each chunk contains a portion of the response text.

Providers that implement streamingcapableinterface support streaming. Check provider capabilities before using:

Example: Checking streaming support
$provider = $this->llmManager->getProvider('openai');
if ($provider instanceof StreamingCapableInterface) {
    // Provider supports streaming
}
Copied!

Tool/function calling 

Tool calling (also known as function calling) allows the LLM to request execution of functions you define. The model decides when to call a tool based on the conversation context.

Sequence: the application sends a chat request with tool definitions, the model answers with tool calls, the tool gate admits or denies each call, the application executes the admitted ones and returns their results, and the model produces the final answer.

One round of the bounded loop. The gate runs before the model is offered anything, and a call whose tool declares a write effect suspends the run for approval before it executes.

Defining tools 

Example: Tool/function calling
$tools = [
    [
        'type' => 'function',
        'function' => [
            'name' => 'get_weather',
            'description' => 'Get current weather for a location',
            'parameters' => [
                'type' => 'object',
                'properties' => [
                    'location' => [
                        'type' => 'string',
                        'description' => 'City name',
                    ],
                    'unit' => [
                        'type' => 'string',
                        'enum' => ['celsius', 'fahrenheit'],
                    ],
                ],
                'required' => ['location'],
            ],
        ],
    ],
];
Copied!

Executing tool calls 

CompletionResponse::$toolCalls is a list of NetresearchNrLlmDomainValueObjectToolCall value objects — $toolCall->arguments is already a JSON-decoded associative array, so no manual json_decode() is needed. The two follow-up turns are built with the ChatMessage factories: ChatMessage::assistantToolCalls() echoes the assistant turn that carries the tool calls, and ChatMessage::toolResult() answers one call by its id.

Example: Handling tool call responses
use Netresearch\NrLlm\Domain\ValueObject\ChatMessage;

$response = $this->llmManager->chatWithTools($messages, $tools);

if ($response->hasToolCalls()) {
    // Echo the assistant turn (with all its tool calls) back first
    $messages[] = ChatMessage::assistantToolCalls($response->toolCalls, $response->content);

    foreach ($response->toolCalls as $toolCall) {
        // Execute your function — $toolCall->arguments is a decoded array
        $result = match ($toolCall->name) {
            'get_weather' => $this->getWeather($toolCall->arguments['location']),
            default => throw new \RuntimeException("Unknown function: {$toolCall->name}"),
        };

        // Answer the call by its id
        $messages[] = ChatMessage::toolResult($toolCall->id, json_encode($result, JSON_THROW_ON_ERROR));
    }

    // Ask the model to answer with the tool results in context
    $response = $this->llmManager->chat($messages);
}
Copied!

Providers that implement toolcapableinterface support tool calling.

Creating custom providers 

Implement a custom provider by extending AbstractProvider:

Example: Custom provider implementation
<?php

namespace MyVendor\MyExtension\Provider;

use Netresearch\NrLlm\Provider\AbstractProvider;
use Netresearch\NrLlm\Provider\Contract\ProviderInterface;

class MyCustomProvider extends AbstractProvider implements ProviderInterface
{
    protected string $baseUrl = 'https://api.example.com/v1';

    public function getName(): string
    {
        return 'My Custom Provider';
    }

    public function getIdentifier(): string
    {
        return 'custom';
    }

    public function isConfigured(): bool
    {
        return !empty($this->apiKey);
    }

    public function chatCompletion(array $messages, array $options = []): CompletionResponse
    {
        $payload = $this->buildChatPayload($messages, $options);
        $response = $this->sendRequest('chat', $payload);

        return new CompletionResponse(
            content: $response['choices'][0]['message']['content'],
            model: $response['model'],
            usage: $this->parseUsage($response['usage']),
            finishReason: $response['choices'][0]['finish_reason'],
            provider: $this->getIdentifier(),
        );
    }

    // Implement other required methods...
}
Copied!

Registering your provider 

Register your provider in Services.yaml:

Configuration/Services.yaml
MyVendor\MyExtension\Provider\MyCustomProvider:
  arguments:
    $httpClient: '@Psr\Http\Client\ClientInterface'
    $requestFactory: '@Psr\Http\Message\RequestFactoryInterface'
    $streamFactory: '@Psr\Http\Message\StreamFactoryInterface'
    $logger: '@Psr\Log\LoggerInterface'
  tags:
    - name: nr_llm.provider
      priority: 50
Copied!

What an adapter has to answer to 

Every bundled adapter passes one shared contract case, TestsUnitProviderContractAbstractAdapterContractTestCase (ADR-160: One adapter contract, and honest capability provenance). It is the readable statement of what an adapter is expected to do, so read it before writing one — and extend it in your own test suite if you want the same guarantees:

Identifier
getIdentifier() returns the stable registration key, and that same key travels on every CompletionResponse.
Capability declaration
The capability interfaces an adapter implements and the features it lists in $supportedFeatures must agree. The service layer reads the first, LlmServiceManager::supportsFeature() reads the second; a disagreement gives two callers opposite answers about the same adapter.
Error normalisation
401 becomes ProviderAuthenticationException, 429 becomes ProviderRateLimitException, any other 4xx becomes ProviderResponseException carrying the provider's own message, a 5xx and an undecodable 2xx body become ProviderConnectionException. Nothing leaves an adapter as a raw transport exception.
No credential, no request
An adapter that needs an API key throws ProviderConfigurationException before it builds a request, rather than sending without one and letting the provider answer 401. A keyless provider — a local Ollama — declares that with requiresApiKey(): false and the contract skips by name.
Timeout behaviour
A client-side timeout surfaces as a connection failure and is attempted exactly once. Retrying a timeout multiplies the caller's wait by the attempt count.
Usage reporting
Token counts come from the provider's own counters; the total is derived. A response with no usage block degrades to zero rather than failing, because cost accounting writes a row per call.
Tool calls and structured output
Where the adapter declares the capability: a provider tool call arrives as a typed ToolCall with a non-empty id, the declared tools reach the request, a strict schema is enforced through the provider's native shape, a schema the provider cannot enforce degrades instead of earning a 400, and a malformed structured answer is passed back untouched so CompletionService can run its one repair attempt.

A capability the adapter does not have is skipped by name in the run output. That is deliberate: it keeps "cannot" distinguishable from "not tested".

The declared deviations 

Two rules above are not universal, and the contract says which adapter breaks them rather than softening the rule for everyone:

  • OpenRouterProvider maps a 5xx other than 503 to ProviderResponseException, not ProviderConnectionException, because it carries its own request path for the attribution headers and the 402 = out-of-credits mapping. 503 has its own arm there and stays ProviderConnectionException, matching the shared path. Retry and fallback are unaffected either way — FailureClassifier reads the carried HTTP status, so a 5xx classifies as SERVER_ERROR and hops. What differs is the class a caller catches and the message text.
  • The same adapter does not retry transport failures at all; that path has no retry loop, so maxRetries is inert for it.

Both are declared as overrides in TestsUnitProviderContractOpenRouterAdapterContractTest, with the reasoning in each override's docblock. See ADR-160: One adapter contract, and honest capability provenance.

Registering a provider 

Two mechanisms pick up your provider class. Use the attribute when you can.

Preferred: the #[AsLlmProvider] attribute 

Add the attribute to any provider class that lives under the Netresearch\NrLlm\ namespace. The compiler pass auto-tags the service, sets it public (so backend diagnostics can resolve it by class name), and registers it with LlmServiceManager in priority order:

Classes/Provider/MyProvider.php
use Netresearch\NrLlm\Attribute\AsLlmProvider;
use Netresearch\NrLlm\Provider\AbstractProvider;

#[AsLlmProvider(priority: 85)]
final class MyProvider extends AbstractProvider
{
    public function getIdentifier(): string
    {
        return 'my-provider';
    }

    public function getName(): string
    {
        return 'My LLM Service';
    }

    // ... chatCompletion(), embeddings(), supportsFeature()
}
Copied!

Priority is an ordering hint only. Providers are still resolved by their getIdentifier() at runtime. Higher priority wins when two providers otherwise tie.

Third-party fallback: yaml tagging 

Extensions that sit outside the Netresearch\NrLlm\ namespace still work via the original mechanism — declare a service with the nr_llm.provider tag:

EXT:my_ext/Configuration/Services.yaml
services:
  Acme\MyExt\Provider\AcmeProvider:
    public: true
    tags:
      - name: nr_llm.provider
        priority: 85
Copied!

When both yaml tagging AND the attribute are present on the same service, the yaml wins (the attribute pass skips already-tagged services). Treat this as an override hook rather than an additive mechanism.

Capability interfaces 

Priority governs registration order only; it says nothing about what a provider can do. Capabilities are advertised by implementing the relevant interface from NetresearchNrLlmProviderContract:

  • VisionCapableInterface — image analysis
  • StreamingCapableInterface — SSE streaming
  • ToolCapableInterface — function / tool calling
  • DocumentCapableInterface — PDF / structured document input

LlmServiceManager dispatches to a provider only when the caller's requested operation matches a capability the provider actually advertises. A provider that doesn't implement VisionCapableInterface can never be asked to describe an image, regardless of priority. See ADR-022: Attribute-Based Provider Registration for the attribute-discovery design decision and the Symfony registerAttributeForAutoconfiguration alternative we evaluated.

Fallback chain 

A LlmConfiguration can carry an ordered list of other configuration identifiers to fall back to on retryable provider failures. The lookup happens transparently inside NetresearchNrLlmServiceLlmServiceManager::chatWithConfiguration() and completeWithConfiguration(). Callers see a regular completion response or a typed exception; they never need to reach into retry mechanics.

Configuring a chain 

The tx_nrllm_configuration.fallback_chain column stores a JSON object with a single key, configurationIdentifiers, whose value is the ordered array of target configuration identifiers:

Example payload stored in fallback_chain
{"configurationIdentifiers": ["claude-sonnet", "ollama-local"]}
Copied!

Editors paste that JSON into the Fallback Chain tab in the backend form. The order is the retry order. Identifiers are matched case-insensitively against tx_nrllm_configuration.identifier. Using an object (rather than a bare top-level array) leaves room for future sibling fields — e.g. per-link retry policy — without a schema break.

Retryable vs. non-retryable errors 

Fallback only triggers for errors the next provider might actually recover from:

Exception Retryable?
ProviderConnectionException (network, timeout, HTTP 5xx, retries exhausted) Yes
ProviderResponseException with code 429 (rate-limited by this provider) Yes
ProviderResponseException with any other 4xx (authentication, bad request, not found, …) No. Bubbles up. A different provider with the same input would fail the same way.
ProviderConfigurationException No. Misconfiguration is a human problem.
UnsupportedFeatureException No. Fallback won't make a text-only provider handle images.

When every configuration in the chain trips a retryable error, NetresearchNrLlmProviderExceptionFallbackChainExhaustedException is thrown. It carries the per-attempt errors so consumers can surface the full failure sequence.

Scope limits 

v1 is deliberately narrow:

  • No streaming. streamChatWithConfiguration() does not wrap the call. Once the first chunk has been yielded to the caller, mid-stream provider-switching would be detectable and surprising.
  • No recursion. A fallback configuration's own chain is ignored. This avoids cycles (a -> b -> a) and unbounded attempt trees.
  • Single primary-only chain is a no-op. If the configured chain contains only the primary's own identifier, the primary's original exception is rethrown verbatim rather than wrapped in FallbackChainExhaustedException.

Using the DTO directly 

For programmatic construction — e.g. a wizard that generates a configuration and also sets up fallback — use the NetresearchNrLlmDomainDTOFallbackChain value object:

EXT:my_ext/Classes/Service/Setup.php
use Netresearch\NrLlm\Domain\DTO\FallbackChain;

$chain = (new FallbackChain())
    ->withLink('claude-sonnet')
    ->withLink('ollama-local');

$configuration->setFallbackChainDTO($chain);
Copied!

The DTO trims and lowercases identifiers on entry, deduplicates them, and silently rejects empty strings and non-string entries read from malformed JSON. See ADR-021: Provider Fallback Chain for the full design rationale and the alternatives we ruled out.

Configuration presets 

A consuming extension can declare the LlmConfiguration records it needs as configuration presets. nr_llm lists declared-but-not-yet imported presets as pending; a backend admin imports one with a single confirmation. See ADR-056 for the design rationale.

A preset expresses requirements — model capabilities and constraints as ModelSelectionCriteria — never a concrete provider, model, or API key. The imported record runs in criteria selection mode, so ModelSelectionService resolves the actual model on every run against whatever the admin has configured.

Declaring presets in your extension 

Implement ConfigurationPresetProviderInterface. The nr_llm.configuration_preset DI tag is applied automatically when your extension's Services.yaml has autoconfigure: true (the TYPO3 default):

<?php

declare(strict_types=1);

namespace Vendor\NrAiSearch\Integration;

use Netresearch\NrLlm\Domain\DTO\ModelSelectionCriteria;
use Netresearch\NrLlm\Service\Preset\ConfigurationPreset;
use Netresearch\NrLlm\Service\Preset\ConfigurationPresetProviderInterface;

final class AiSearchPresetProvider implements ConfigurationPresetProviderInterface
{
    public function getPresets(): array
    {
        return [
            new ConfigurationPreset(
                identifier: 'nr_ai_search.chat',
                name: 'AI Search Chat',
                description: 'Answers site-search questions with tool support.',
                criteria: new ModelSelectionCriteria(
                    capabilities: ['chat', 'tools'],
                    minContextLength: 8000,
                ),
                systemPrompt: 'You answer questions about this website.',
                temperature: 0.2,
                maxTokens: 2000,
            ),
            new ConfigurationPreset(
                identifier: 'nr_ai_search.embedding',
                name: 'AI Search Embeddings',
                description: 'Creates embeddings for the search index.',
                criteria: new ModelSelectionCriteria(
                    capabilities: ['embedding'],
                    preferLowestCost: true,
                ),
            ),
        ];
    }
}
Copied!

Rules the value object enforces at construction time:

  • The identifier is lowercase [a-z0-9_] segments separated by dots and must be namespaced with your extension key (nr_ai_search.chat) so presets from different extensions cannot collide. Duplicate identifiers across providers fail fast at container build time.
  • The criteria must require at least one capability.

All other fields (system prompt, temperature, max tokens, the daily budget ceilings maxRequestsPerDay / maxTokensPerDay / maxCostPerDay, allowedToolGroups) are optional seeds; null keeps the column default of the created record.

At runtime, resolve the imported configuration by its identifier as usual, for example through LlmConfigurationServiceInterface.

Import flow 

  1. The admin queries the pending presets (AJAX route nrllm_preset_list). Each entry carries a preflight result: whether the criteria currently match an active model (satisfiable + matchedModelLabel), or which requirement eliminates every candidate (missingRequirement).
  2. The admin confirms one import (AJAX route nrllm_preset_import with the preset identifier). nr_llm creates an active, criteria-mode tx_nrllm_configuration record and stores the preset's checksum in preset_checksum.
  3. The record is a normal configuration from then on — the admin can edit or delete it. A preset whose identifier already has a record is never offered again (and an import attempt is refused), so imports are idempotent; the stored checksum makes a changed declaration detectable.

Both endpoints are restricted to backend administrators (ADR-037). Admins normally go through the Configurations backend module, which renders the pending presets — including each preflight result — above the configuration records and imports one through nrllm_preset_import with a single click; see Importing configuration presets.

Change detection 

nrllm_preset_list additionally returns a drifted list: imported presets whose current declaration checksum no longer matches the preset_checksum stored at import time. Each entry carries identifier, name, configurationUid, and changedFields — the machine names of the fields an update would overwrite (an additive summary; may be empty when the declaration only dropped an optional seed). The Configurations module flags such records with a non-blocking "Preset changed" hint next to a Review update action.

nr_llm never updates an imported record automatically, but the admin can review and apply a changed declaration.

Update flow 

Two further admin-gated AJAX endpoints resolve drift:

  1. nrllm_preset_diff (GET, identifier) returns the field-level changes an update would apply — each a field (machine name, e.g. temperature or criteria.capabilities), the record's current value, and the declared value. It refuses (422) when there is nothing to update: the record is up to date, was not imported from a preset, was switched to fixed model selection, or the changed criteria are currently unsatisfiable.
  2. nrllm_preset_update (POST, identifier) applies a reviewed update after the admin re-confirmed, then returns the changedFields that were applied.

An update follows the declaration for name, description and criteria, and for each optional seed that carries a value; a seed the declaration left null does not reset the record. It leaves the admin-owned fields untouched — active state, default flag, backend groups, and the fallback chain — and re-stamps the stored checksum so the drift hint clears. See ADR-056 for the design.

When your extension changes a preset declaration, ship the change; admins see the drift hint and re-confirm the diff. Document only anything the flow cannot carry (for example a change that also needs a new provider or model configured first).

Quality evaluation 

nr_llm can measure the quality of the answers a model produces against golden prompt sets and detect regressions between runs. Evaluation is an explicitly triggered, out-of-request operation — it never runs in the request pipeline and, with the default grader, spends no tokens. See ADR-060 for the design rationale.

A golden set is a collection of prompts, each with the expectations it should satisfy. A run executes the set against a model, grades every response, aggregates the results to a pass rate and mean score, stores the run, and compares it against the previous run for the same set and model.

Declaring a golden set in your extension 

Implement GoldenPromptSetProviderInterface. The nr_llm.golden_prompt_set DI tag is applied automatically when your extension's Services.yaml has autoconfigure: true (the TYPO3 default):

<?php

declare(strict_types=1);

namespace Vendor\NrAiSearch\Evaluation;

use Netresearch\NrLlm\Service\Evaluation\Assertion;
use Netresearch\NrLlm\Service\Evaluation\GoldenPrompt;
use Netresearch\NrLlm\Service\Evaluation\GoldenPromptSet;
use Netresearch\NrLlm\Service\Evaluation\GoldenPromptSetProviderInterface;

final class AiSearchGoldenSetProvider implements GoldenPromptSetProviderInterface
{
    public function getGoldenPromptSets(): array
    {
        return [
            new GoldenPromptSet(
                identifier: 'nr_ai_search.faq',
                name: 'AI Search FAQ answers',
                description: 'Checks the model answers common FAQ prompts correctly.',
                prompts: [
                    new GoldenPrompt(
                        id: 'opening-hours',
                        prompt: 'When is the office open? Answer with the days.',
                        assertions: [
                            Assertion::contains('Monday'),
                            Assertion::regex('/9(:00)?\s*(am|–|-)/i'),
                        ],
                        reference: 'Monday to Friday, 9am to 5pm.',
                    ),
                    new GoldenPrompt(
                        id: 'contact-json',
                        prompt: 'Return the contact as JSON with an "email" field.',
                        assertions: [
                            Assertion::jsonSchema('{"type":"object","required":["email"],"properties":{"email":{"type":"string"}}}'),
                        ],
                    ),
                ],
            ),
        ];
    }
}
Copied!

The identifier is namespaced (vendor_extension.set) so sets from different extensions cannot collide. Each prompt needs at least one assertion or a reference answer.

Assertion types 

The deterministic grader supports four assertion types; a prompt passes only when all of its assertions hold, and the score is the fraction satisfied.

Type Factory Passes when
Exact Assertion::exact($value) the trimmed response equals $value
Contains Assertion::contains($value) $value is a substring of the response
Regex Assertion::regex($pattern) the response matches the PCRE $pattern
JSON schema Assertion::jsonSchema($schemaJson) the response is valid JSON satisfying the structural schema

The json_schema matcher is a lightweight structural check — a top-level type, object required keys, and recursive properties types. Extra keys are allowed. It is intentionally not a full JSON Schema draft validator.

Graders 

Two grading strategies sit behind GraderInterface:

  • deterministic (default) — evaluates the assertions with no LLM call and no tokens.
  • llm_judge (opt-in) — asks a judge model through CompletionService to score the response 0.01.0 with a justification. It uses the reference answer when one is declared. Because it spends tokens, it runs only when explicitly selected; an unknown grader name falls back to the deterministic grader.

Running an evaluation 

Use the nrllm:eval:run command:

# Deterministic grading against the configured default model
vendor/bin/typo3 nrllm:eval:run nr_ai_search.faq

# Evaluate a specific model with the LLM judge
vendor/bin/typo3 nrllm:eval:run nr_ai_search.faq --model gpt-5.2 --grader llm_judge

# Fail (non-zero exit) if quality regressed against the previous run — for CI
vendor/bin/typo3 nrllm:eval:run nr_ai_search.faq --fail-on-regression
Copied!

The command prints the per-prompt gradings and the aggregate (pass rate, mean score), stores the run in tx_nrllm_eval_result, and reports whether the run regressed against the previous run for the same set and model. The regression tolerance is configurable with --max-pass-rate-drop and --max-mean-score-drop (both default to 0.1).

nr_llm ships an example set, nr_llm.smoke, so the command is runnable out of the box.

Quality-aware routing (opt-in) 

Stored evaluation results feed an opt-in routing hook. The existing cost/latency selection modes of ModelSelectionService are unchanged; nothing routes by quality unless you call the hook explicitly:

use Netresearch\NrLlm\Service\Evaluation\QualityAwareModelSelector;

// Inject QualityAwareModelSelector, then:
$model = $selector->selectByQuality(
    ['capabilities' => ['chat']],
    minQuality: 0.7,
);
Copied!

selectByQuality() takes the candidates ModelSelectionService would return for the criteria and re-ranks them by measured quality score (latest run per set, averaged). Candidates without evaluation data keep their base order behind the scored ones; with minQuality set, candidates below it (or without data) are excluded. Making quality a first-class sort key inside ModelSelectionService is a planned follow-up (see ADR-060).

Retrieval evaluation 

The retrieval counterpart measures the retrieval step of a RAG pipeline — which documents surface for a question — with golden question sets and document-level top-1/top-3 hit rates. See ADR-072 for the design and the methodology it adopts.

A golden question carries the question text, its form (MATCH = the vocabulary overlaps the target document, GAP = an everyday rewording — the class retrieval problems live in), ALL document ids that answer it (any of them counts as a hit), an optional hard class for a per-class breakdown, and an optional answer gist documenting the label. A question with an empty expected-document list declares that nothing in the index answers it and scores as a hit only when the retriever returns nothing. nr_llm ships no golden questions — labels only mean something against a concrete corpus, so every set lives in the extension owning the content.

Declare a set by implementing GoldenQuestionSetProviderInterface (tag nr_llm.golden_question_set, applied automatically):

use Netresearch\NrLlm\Domain\Enum\QuestionForm;
use Netresearch\NrLlm\Service\Evaluation\GoldenQuestion;
use Netresearch\NrLlm\Service\Evaluation\GoldenQuestionSet;
use Netresearch\NrLlm\Service\Evaluation\GoldenQuestionSetProviderInterface;

final class BmdvGoldenQuestionSetProvider implements GoldenQuestionSetProviderInterface
{
    public function getGoldenQuestionSets(): array
    {
        return [
            new GoldenQuestionSet(
                identifier: 'nr_ai_search.bmdv',
                name: 'BMDV retrieval eval',
                description: 'Labeled questions over the BMDV corpus.',
                questions: [
                    new GoldenQuestion(
                        id: 'dialogforum-termin',
                        question: 'Wann findet das Dialogforum statt?',
                        form: QuestionForm::MATCH,
                        expectedDocumentIds: ['234_0', '309_0'],
                        hardClass: 'near-duplicate',
                    ),
                ],
            ),
        ];
    }
}
Copied!

The retriever under test implements EvaluatableRetrieverInterface (tag nr_llm.evaluatable_retriever): a question string and a limit in, ranked document ids out. The adapter owns the mapping from its native results to document ids, which must use the same identity scheme as the set's labels. nr_llm ships LexicalSearchRetriever (nr_llm.lexical) over its own search cascade as the pattern to copy; a consumer wraps its vector retrieval the same way.

Run with the nrllm:eval:retrieval command:

# Measure the built-in lexical cascade against a labeled set
vendor/bin/typo3 nrllm:eval:retrieval nr_ai_search.bmdv nr_llm.lexical

# Fail (non-zero exit) if hit rates regressed — for CI
vendor/bin/typo3 nrllm:eval:retrieval nr_ai_search.bmdv nr_ai_search.vector \
    --fail-on-regression --max-top1-drop 0.05 --max-top3-drop 0.05
Copied!

The command prints the per-question hits, the top-1/top-3 hit rates with by-form and by-hard-class breakdowns, stores the run in tx_nrllm_eval_result (grader retrieval_hit_rate; the stored pass rate is the top-1 hit rate and the stored mean score the top-3 hit rate), and reports whether the run regressed against the previous one for the same set and retriever.

Build your extension on nr-llm 

This guide walks you through adding AI capabilities to a TYPO3 extension using nr-llm as a dependency. By the end, your extension will have working AI features without any provider-specific code.

Why build on nr-llm? 

When your extension calls an LLM API directly, it takes on responsibility for:

  • HTTP client setup, authentication, and error handling per provider
  • Secure API key storage (not in ext_conf_template.txt or $GLOBALS)
  • Response caching to control costs
  • Streaming implementation for real-time UX
  • A configuration UI for administrators

nr-llm handles all of this. Your extension focuses on what to ask the AI, not how to reach it.

Step 1: Add the dependency 

Install nr-llm
composer require netresearch/nr-llm
Copied!

Add the dependency to your ext_emconf.php:

ext_emconf.php
'constraints' => [
    'depends' => [
        'typo3' => '13.4.0-14.99.99',
        'nr_llm' => '0.4.0-0.99.99',
    ],
],
Copied!

Step 2: Inject the service 

All nr-llm services are available via TYPO3's dependency injection. Pick the service that matches your use case:

Classes/Service/MyAiService.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Service;

use Netresearch\NrLlm\Service\LlmServiceManagerInterface;

final readonly class MyAiService
{
    public function __construct(
        private LlmServiceManagerInterface $llm,
    ) {}

    public function summarize(string $text): string
    {
        $response = $this->llm->complete(
            "Summarize the following text in 2-3 sentences:\n\n" . $text,
        );

        return $response->content;
    }
}
Copied!

No Services.yaml configuration needed — TYPO3's autowiring handles it.

Step 3: Use feature services for specialized tasks 

For common AI tasks, use the specialized feature services instead of raw chat:

Translation example
use Netresearch\NrLlm\Service\Feature\TranslationService;

final readonly class ContentTranslator
{
    public function __construct(
        private TranslationService $translator,
    ) {}

    public function translateToGerman(string $text): string
    {
        $result = $this->translator->translate($text, 'de');
        return $result->translation;
    }
}
Copied!
Image analysis example
use Netresearch\NrLlm\Service\Feature\VisionService;

final readonly class ImageMetadataGenerator
{
    public function __construct(
        private VisionService $vision,
    ) {}

    public function generateAltText(string $imageUrl): string
    {
        return $this->vision->generateAltText($imageUrl);
    }
}
Copied!
Embedding / similarity example
use Netresearch\NrLlm\Service\Feature\EmbeddingService;

final readonly class ContentRecommender
{
    public function __construct(
        private EmbeddingService $embeddings,
    ) {}

    /**
     * @param list<array{id: int, text: string, vector: list<float>}> $candidates
     * @return list<int> Top 5 most similar content IDs
     */
    public function findSimilar(string $query, array $candidates): array
    {
        $queryVector = $this->embeddings->embed($query);
        $results = $this->embeddings->findMostSimilar(
            $queryVector,
            array_column($candidates, 'vector'),
            topK: 5,
        );

        return array_map(
            fn(int $index) => $candidates[$index]['id'],
            array_keys($results),
        );
    }
}
Copied!

Step 4: Handle errors gracefully 

nr-llm throws typed exceptions so you can provide meaningful feedback:

Error handling with typed exceptions
use Netresearch\NrLlm\Provider\Exception\ProviderConfigurationException;
use Netresearch\NrLlm\Provider\Exception\ProviderConnectionException;
use Netresearch\NrLlm\Provider\Exception\ProviderResponseException;

try {
    $response = $this->llm->complete($prompt);
} catch (ProviderConfigurationException) {
    // No provider configured — guide the admin
    return 'AI features require LLM configuration. '
         . 'An administrator can set this up in Admin Tools > LLM.';
} catch (ProviderConnectionException) {
    // Network issue — suggest retry
    return 'Could not reach the AI provider. Please try again.';
} catch (ProviderResponseException $e) {
    // Provider returned an error (rate limit, invalid input, etc.)
    $this->logger->warning('LLM provider error', ['exception' => $e]);
    return 'The AI service returned an error. Please try again later.';
}
Copied!

Step 5: Use database configurations (optional) 

For advanced use cases, reference named configurations that admins create in the backend module. Resolve them through NetresearchNrLlmServiceConfigurationResolver::getActiveByIdentifier() — it works in user-less contexts (CLI, Symfony Messenger consumers, anonymous frontend requests) and applies the guards a raw repository lookup skips (ADR-070):

Using named database configurations
use Netresearch\NrLlm\Service\ConfigurationResolver;
use Netresearch\NrLlm\Service\LlmServiceManagerInterface;

final readonly class BlogSummarizer
{
    public function __construct(
        private ConfigurationResolver $configurationResolver,
        private LlmServiceManagerInterface $llm,
    ) {}

    public function summarize(string $article): string
    {
        // Uses the "blog-summarizer" configuration created by the admin
        // (specific model, temperature, system prompt, etc.)
        $config = $this->configurationResolver->getActiveByIdentifier('blog-summarizer');

        $response = $this->llm->chat(
            [['role' => 'user', 'content' => "Summarize:\n\n" . $article]],
            $config->toChatOptions(),
        );

        return $response->content;
    }
}
Copied!

The method throws typed exceptions (all implementing NrLlmExceptionInterface): ConfigurationNotFoundException when no record with the identifier exists, ConfigurationInactiveException when it exists but is deactivated, and AccessDeniedException when it is restricted to backend groups (user-less callers cannot prove group membership; see ADR-070).

Step 6: Declare the configurations you need (optional) 

Instead of documenting "please create a configuration named myext.chat" in prose, declare it as a configuration preset: implement ConfigurationPresetProviderInterface (auto-tagged nr_llm.configuration_preset) and express your requirements as ModelSelectionCriteria — never a concrete provider, model, or API key. nr_llm lists your presets as pending and a backend admin imports them with one confirmation as criteria-mode configuration records.

See Configuration presets for the declaration example and import flow.

Testing your integration 

Mock the nr-llm interfaces in your unit tests:

Tests/Unit/Service/MyAiServiceTest.php
use Netresearch\NrLlm\Domain\Model\CompletionResponse;
use Netresearch\NrLlm\Domain\Model\UsageStatistics;
use Netresearch\NrLlm\Service\LlmServiceManagerInterface;
use PHPUnit\Framework\TestCase;

final class MyAiServiceTest extends TestCase
{
    public function testSummarizeReturnsCompletionContent(): void
    {
        $llm = $this->createStub(LlmServiceManagerInterface::class);
        $llm->method('complete')->willReturn(
            new CompletionResponse(
                content: 'A short summary.',
                model: 'gpt-5.3-instant',
                usage: new UsageStatistics(50, 20, 70),
                finishReason: 'stop',
                provider: 'openai',
            ),
        );

        $service = new MyAiService($llm);
        self::assertSame('A short summary.', $service->summarize('Long text...'));
    }
}
Copied!

Integration checklist 

  1. composer.json — Added netresearch/nr-llm to require
  2. ext_emconf.php — Added nr_llm to depends constraints
  3. Services — Inject LlmServiceManagerInterface or feature services via DI
  4. Error handling — Catch typed exceptions and show user-friendly messages
  5. Testing — Mock LlmServiceManagerInterface in unit tests
  6. Documentation — Tell your users they need to configure a provider in Admin Tools > LLM

Protecting anonymous LLM-cost-bearing endpoints 

Every request that reaches an anonymous frontend endpoint backed by nr-llm (a search box, a chat widget, a content assistant) triggers a paid provider call. A single scripted attacker — or one careless crawler — can turn such an endpoint into an open cost faucet.

nr-llm's per-user budgets cap the aggregate spend attributed to a backend user, so a runaway endpoint cannot exceed its monthly ceiling. Budgets do not, however, limit the request rate of an individual attacker: one IP can still burn the whole budget and deny the feature to everyone else. Per-request protection is the consuming extension's responsibility, at its own HTTP surface.

This page shows the three patterns to apply. They are small, build only on TYPO3 core and Symfony primitives, and need no extra infrastructure.

Per-IP rate limiting 

TYPO3 core already depends on symfony/rate-limiter (core's own login rate limiting uses it) and ships a storage adapter that persists limiter state in the caching framework: TYPO3CMSCoreRateLimiterStorageCachingFrameworkStorage. The whole recipe is one service alias plus a  25-line class.

Require the component explicitly, since your code uses its classes directly:

Add the dependency
composer require symfony/rate-limiter
Copied!

Alias the Symfony storage interface to TYPO3's caching-framework-backed implementation:

Configuration/Services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false

    Symfony\Component\RateLimiter\Storage\StorageInterface:
        alias: TYPO3\CMS\Core\RateLimiter\Storage\CachingFrameworkStorage
Copied!

The limiter itself keys on the client IP resolved through the normalizedParams request attribute — a NormalizedParams instance TYPO3 sets on every frontend and backend request — which honors TYPO3's reverse-proxy configuration ($GLOBALS['TYPO3_CONF_VARS']['SYS']['reverseProxyIP']) instead of trusting REMOTE_ADDR blindly. The raw REMOTE_ADDR fallback only covers requests that never passed through TYPO3's request handling, such as unit tests:

Classes/Infrastructure/RequestRateLimiter.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Infrastructure;

use Psr\Http\Message\ServerRequestInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\RateLimiter\Storage\StorageInterface;
use TYPO3\CMS\Core\Http\NormalizedParams;

final readonly class RequestRateLimiter
{
    private RateLimiterFactory $rateLimiterFactory;

    public function __construct(
        StorageInterface $storage,
        int $limitPerMinute = 30,
        string $limiterId = 'myext_frontend',
    ) {
        $this->rateLimiterFactory = new RateLimiterFactory(
            [
                'id' => $limiterId,
                'policy' => 'sliding_window',
                'limit' => $limitPerMinute,
                'interval' => '1 minute',
            ],
            $storage,
        );
    }

    public function tooManyRequests(ServerRequestInterface $request): bool
    {
        $normalizedParams = $request->getAttribute('normalizedParams');

        $remoteIp = $normalizedParams instanceof NormalizedParams
            ? $normalizedParams->getRemoteAddress()
            : (string)($request->getServerParams()['REMOTE_ADDR'] ?? '');

        return !$this->rateLimiterFactory
            ->create($remoteIp)
            ->consume()
            ->isAccepted();
    }
}
Copied!

Design choices worth keeping:

  • sliding_window avoids the burst-at-window-boundary problem of the fixed_window policy: an attacker cannot double the effective rate by straddling two windows.
  • The limiter id partitions the counters. Give each cost-bearing endpoint its own id (and, if the costs differ, its own limit) by registering separately configured instances:
Configuration/Services.yaml — one limiter per endpoint
services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false

    MyVendor\MyExtension\Infrastructure\RequestRateLimiter: ~

    myext.rate_limiter.chat:
        class: MyVendor\MyExtension\Infrastructure\RequestRateLimiter
        arguments:
            $limiterId: 'myext_chat'
Copied!

The ~ (null) definition inherits everything from _defaults: with autowire: true the StorageInterface constructor argument resolves through the alias above, and the two scalar parameters keep their declared defaults. Without the _defaults block (or an explicit autowire: true on the definition) the container would fail to instantiate the service.

  • Where the limit value comes from — extension configuration, a site setting, or a constructor default — is your choice. If you read it from configuration, validate it at construction time and fail hard on a missing or non-positive value rather than silently running unlimited.

Rejecting cross-site requests 

Browsers implementing the Fetch Metadata spec send a Sec-Fetch-Site header with every request. Rejecting the value cross-site stops other origins from firing forged POSTs at your endpoint through visitors' browsers — a cost-exhaustion vector that per-IP limiting alone spreads across many victim IPs instead of stopping:

Classes/Infrastructure/FrontendRequestInspector.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Infrastructure;

use Psr\Http\Message\ServerRequestInterface;

final readonly class FrontendRequestInspector
{
    public function isCrossSiteRequest(
        ServerRequestInterface $request,
    ): bool {
        return $request->getHeaderLine('Sec-Fetch-Site') === 'cross-site';
    }
}
Copied!

The check fails open by design. A request without the header (an older browser, or a non-browser client such as curl) is not blocked. This is deliberate, not an oversight: all current browsers send the header automatically, so the check reliably covers the browser-mediated cross-site attack it targets, while non-browser clients — which never send Fetch Metadata — are exactly what the rate limiter above handles. Treat this check as defense in depth on top of the rate limiter, never as the sole protection.

Never-leak error shaping 

An anonymous endpoint must not reveal why a request was refused or what failed internally. Provider names, exception messages, configuration paths, and budget states are reconnaissance material. Shape every failure path to the same generic, translated message, and keep the diagnostic detail in server-side logs only:

Controller action combining all three patterns
use InvalidArgumentException;
use Netresearch\NrLlm\Provider\Exception\ProviderException;
use Psr\Http\Message\ResponseInterface;

// … inside the controller class:

public function searchAction(string $query = ''): ResponseInterface
{
    if ($this->requestInspector->isCrossSiteRequest($this->request)
        || $this->rateLimiter->tooManyRequests($this->request)
    ) {
        $this->view->assign('message', $this->translator->translate(
            'search.rateLimited',
            'Too many requests - please wait a moment and try again.',
        ));

        return $this->htmlResponse();
    }

    try {
        $answer = $this->queryFlow->answer(trim($query));
    } catch (ProviderException | InvalidArgumentException) {
        // The exception carries its diagnostic context for server-side
        // logging; none of it is forwarded to the response.
        $this->view->assign('message', $this->translator->translate(
            'search.error',
            'Something went wrong. Please try again later.',
        ));

        return $this->htmlResponse();
    }

    $this->view->assign('answer', $answer);

    return $this->htmlResponse();
}
Copied!

Two details matter:

  • Catch InvalidArgumentException (or your own input-validation exception) alongside the provider exceptions. Value-object constructors that enforce structural invariants (query length caps, range checks) throw it for attacker-controlled input; uncaught, that surfaces as an HTTP 500 with a stack trace instead of the generic message.
  • Use the same response shape for the rate-limited and the failed path. Distinguishable responses let an attacker probe which control they tripped.

Reference implementation 

The nr_ai_search extension (Netresearch) applies all three patterns to its anonymous search and chat plugins: its RequestRateLimiter and FrontendRequestInspector classes in Classes/Infrastructure/ match the recipes above, with the limit read from extension configuration and a separately configured limiter id per controller.

Rendering LLM Markdown server-side safely 

LLM responses are untrusted output (see best practices): the model can echo raw HTML, javascript: links, or attacker-supplied fragments straight from its prompt context back into its answer. If your extension converts that Markdown to HTML on the server and injects it into a page, the converter configuration is a security boundary.

This page shows the hardened server-side recipe, and when to prefer the alternative that nr-llm itself uses — client-side escaping plus sandboxed iframes.

Hardened league/commonmark configuration 

league/commonmark is not a dependency of nr-llm; add it to your extension:

Add the dependency
composer require "league/commonmark:^2.4"
Copied!

The library's own defaults are unsafe for LLM output: html_input defaults to allow (raw HTML in the Markdown passes through verbatim) and allow_unsafe_links defaults to true (javascript: and data: link destinations are kept). Both must be overridden explicitly:

Configuration/Services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true
        public: false

    League\CommonMark\CommonMarkConverter:
        arguments:
            $config:
                html_input: 'strip'
                allow_unsafe_links: false

    League\CommonMark\ConverterInterface:
        alias: League\CommonMark\CommonMarkConverter
Copied!

html_input: strip removes any raw HTML the model emitted; allow_unsafe_links: false suppresses link and image destinations with unsafe schemes such as javascript:.

Escaping fallback on converter failure 

The converter can throw — on malformed input or on a configuration error. Neither case may end in an uncaught exception (denial of service via crafted output) or in emitting the raw text unescaped. Fall back to htmlspecialchars(), which yields correctly escaped, if unformatted, output:

Classes/Presentation/SafeMarkdownRenderer.php
<?php

declare(strict_types=1);

namespace MyVendor\MyExtension\Presentation;

use League\CommonMark\ConverterInterface;
use League\CommonMark\Exception\CommonMarkException;
use League\Config\Exception\ConfigurationExceptionInterface;

final readonly class SafeMarkdownRenderer
{
    public function __construct(
        private ConverterInterface $markdownConverter,
    ) {}

    public function render(string $llmMarkdown): string
    {
        try {
            return $this->markdownConverter
                ->convert($llmMarkdown)
                ->getContent();
        } catch (CommonMarkException | ConfigurationExceptionInterface) {
            return htmlspecialchars(
                $llmMarkdown,
                \ENT_QUOTES | \ENT_HTML5,
            );
        }
    }
}
Copied!

In a Fluid template, output the result raw — it is already HTML — and never pass it through another escaping layer that would double-encode it:

Resources/Private/Templates/Show.html
<div class="llm-answer">{answerHtml -> f:format.raw()}</div>
Copied!

Alternative: client-side escaping and sandboxed iframes 

nr-llm's own backend module deliberately does not render LLM output server-side. Its task-execution view (Resources/Public/JavaScript/Backend/TaskExecute.js) escapes plain, Markdown, and JSON output client-side before insertion, and shows LLM-generated HTML only inside an iframe with sandbox="" — the fully restrictive sandbox, which blocks script execution, form submission, and same-origin access entirely.

Which strategy fits depends on where and how the output is displayed:

Server-side hardened rendering (this page)
Choose it for anonymous frontend pages: the output is complete HTML that works without JavaScript, can be cached with the page, and is identical for every client. The endpoint-protection recipes pair with it on the same controller.
Client-side escaping + sandboxed iframes (nr-llm's approach)
Choose it for interactive views — backend modules, single-page-style widgets — where output arrives incrementally (streaming) and is inserted into a live DOM, or where the feature is previewing LLM-generated HTML itself, which no Markdown converter setting can make safe to inline. The sandbox attribute confines the document instead of sanitizing it.

The two are not exclusive: an extension with a frontend plugin and a backend preview module uses both, each at its own surface.

Reference implementation 

The nr_ai_search extension (Netresearch) implements the server-side recipe for its anonymous search and chat plugins: the hardened converter configuration lives in its Configuration/Services.yaml, and its MarkdownAnswerPresenter (Classes/Provider/) combines the converter with the htmlspecialchars() fallback and server-side href validation for source links.

Feature services 

High-level AI services for TYPO3 with prompt engineering and response parsing.

Overview 

The feature services layer provides domain-specific AI capabilities for TYPO3 extensions. Each service wraps the core LlmServiceManager with specialized prompts, response parsing, and configuration optimized for specific use cases.

Architecture 

Feature services architecture
┌─────────────────────────────────────────────────────────┐
│            Consuming Extensions                          │
│  (rte-ckeditor-image, textdb, contexts)                 │
└──────────────────────┬──────────────────────────────────┘
                       │ Dependency Injection
┌──────────────────────▼──────────────────────────────────┐
│              Feature Services                            │
│  - CompletionService                                     │
│  - VisionService                                         │
│  - EmbeddingService                                      │
│  - TranslationService                                    │
└──────────────────────┬──────────────────────────────────┘
                       │ LLM abstraction
┌──────────────────────▼──────────────────────────────────┐
│              LlmServiceManager                           │
│  (Provider routing, caching, rate limiting)             │
└──────────────────────┬──────────────────────────────────┘
                       │ Provider calls
┌──────────────────────▼──────────────────────────────────┐
│            Provider Implementations                      │
│  (OpenAI, Anthropic, Gemini, etc.)                      │
└─────────────────────────────────────────────────────────┘
Copied!

CompletionService 

Purpose: Text generation and completion.

Use cases 

  • Content generation.
  • Rule generation (contexts extension).
  • Content summarization.
  • SEO meta generation.

Key features 

  • JSON response formatting (native JSON mode on every provider, ADR-128).
  • Schema-validated structured output — a strict JSON-schema subset, provider-enforced, with one repair round-trip (ADR-126).
  • Markdown generation.
  • Factual mode (low creativity).
  • Creative mode (high creativity).
  • System prompt support.

Example 

Example: Using CompletionService
use Netresearch\NrLlm\Service\Feature\CompletionService;

$completion = $completionService->complete(
    prompt: 'Explain TYPO3 in simple terms',
    options: [
        'temperature' => 0.3,
        'max_tokens' => 200,
        'response_format' => 'markdown',
    ]
);

echo $completion->text;
Copied!

Methods 

CompletionService methods
// Standard completion
$response = $completionService->complete($prompt);

// JSON output
$data = $completionService->completeJson('List 5 colors as a JSON array');

// Markdown output
$markdown = $completionService->completeMarkdown('Write docs for this API');

// Factual (low creativity, high consistency)
$response = $completionService->completeFactual('What is the capital of France?');

// Creative (high creativity)
$response = $completionService->completeCreative('Write a haiku about coding');

// Structured: schema-validated JSON (strict subset, ADR-126)
$data = $completionService->completeStructured('Rate this text', [
    'type'       => 'object',
    'required'   => ['score', 'reason'],
    'properties' => [
        'score'  => ['type' => 'number'],
        'reason' => ['type' => 'string'],
    ],
]);
Copied!

VisionService 

Purpose: Image analysis and metadata generation.

Use cases 

  • Alt text generation (rte-ckeditor-image).
  • SEO title generation.
  • Detailed descriptions.
  • Custom image analysis.

Key features 

  • WCAG 2.1 compliant alt text.
  • SEO-optimized titles.
  • Batch processing.
  • Base64 and URL support.

Example 

Example: Using VisionService
use Netresearch\NrLlm\Service\Feature\VisionService;

// Single image
$altText = $visionService->generateAltText(
    'https://example.com/image.jpg'
);

// Batch processing
$altTexts = $visionService->generateAltText([
    'https://example.com/img1.jpg',
    'https://example.com/img2.jpg',
]);
Copied!

Methods 

VisionService methods
// Generate WCAG-compliant alt text
$altText = $visionService->generateAltText('https://example.com/image.jpg');

// Generate SEO-optimized title
$title = $visionService->generateTitle('/path/to/local/image.png');

// Generate detailed description
$description = $visionService->generateDescription($imageUrl);

// Custom analysis
$analysis = $visionService->analyzeImage(
    $imageUrl,
    'What colors are prominent in this image?'
);
Copied!

EmbeddingService 

Purpose: Text-to-vector conversion and similarity search.

Use cases 

  • Semantic translation memory (textdb).
  • Content similarity.
  • Duplicate detection.
  • Semantic search.

Key features 

  • Aggressive caching (deterministic).
  • Batch processing.
  • Cosine similarity calculations.
  • Top-K similarity search.

Example 

Example: Using EmbeddingService
use Netresearch\NrLlm\Service\Feature\EmbeddingService;

// Generate embedding
$vector = $embeddingService->embed('Search query text');

// Find similar
$similar = $embeddingService->findMostSimilar(
    queryVector: $vector,
    candidateVectors: $allVectors,
    topK: 5
);
Copied!

Methods 

EmbeddingService methods
// Generate embedding (cached automatically)
$vector = $embeddingService->embed('Some text');

// Full response with metadata
$response = $embeddingService->embedFull('Some text');

// Batch embedding
$vectors = $embeddingService->embedBatch(['Text 1', 'Text 2']);

// Calculate cosine similarity
$similarity = $embeddingService->cosineSimilarity($vectorA, $vectorB);

// Find most similar vectors
$results = $embeddingService->findMostSimilar(
    $queryVector,
    $candidateVectors,
    topK: 5
);

// Normalize a vector
$normalized = $embeddingService->normalize($vector);
Copied!

TranslationService 

Purpose: Language translation with quality control.

Use cases 

  • Translation suggestions (textdb).
  • Content localization.
  • Glossary-aware translation.

Key features 

  • Language detection.
  • Glossary support.
  • Formality levels.
  • Domain specialization.
  • Quality scoring.

Example 

Example: Using TranslationService
use Netresearch\NrLlm\Service\Feature\TranslationService;

$result = $translationService->translate(
    text: 'The TYPO3 extension is great',
    targetLanguage: 'de',
    options: [
        'glossary' => ['TYPO3' => 'TYPO3'],
        'formality' => 'formal',
        'domain' => 'technical',
    ]
);

echo $result->translation;
echo $result->confidence;
Copied!

Methods 

TranslationService methods
// Basic translation
$result = $translationService->translate('Hello, world!', 'de');

// With options
$result = $translationService->translate(
    $text,
    targetLanguage: 'de',
    sourceLanguage: 'en',
    options: [
        'formality' => 'formal',
        'domain' => 'technical',
        'glossary' => [
            'TYPO3' => 'TYPO3',
            'extension' => 'Erweiterung',
        ],
        'preserve_formatting' => true,
    ]
);

// TranslationResult properties
$translation = $result->translation;
$sourceLanguage = $result->sourceLanguage;
$confidence = $result->confidence;

// Batch translation
$results = $translationService->translateBatch($texts, 'de');

// Language detection
$language = $translationService->detectLanguage($text);

// Quality scoring
$score = $translationService->scoreTranslationQuality($source, $translation, 'de');
Copied!

Installation 

Dependency injection 

Add to your extension's Configuration/Services.yaml:

Configuration/Services.yaml
services:
  Your\Extension\Service\YourService:
    public: true
    arguments:
      $visionService: '@Netresearch\NrLlm\Service\Feature\VisionService'
      $translationService: '@Netresearch\NrLlm\Service\Feature\TranslationService'
      $completionService: '@Netresearch\NrLlm\Service\Feature\CompletionService'
      $embeddingService: '@Netresearch\NrLlm\Service\Feature\EmbeddingService'
Copied!

Usage in your extension 

Example: Using feature services in your extension
<?php

namespace Your\Extension\Service;

use Netresearch\NrLlm\Service\Feature\VisionService;

class YourService
{
    public function __construct(
        private readonly VisionService $visionService
    ) {}

    public function enhanceImage(string $imageUrl): array
    {
        return [
            'alt' => $this->visionService->generateAltText($imageUrl),
            'title' => $this->visionService->generateTitle($imageUrl),
            'description' => $this->visionService->generateDescription($imageUrl),
        ];
    }
}
Copied!

Default prompts 

The extension includes 10 default prompts optimized for common use cases:

Vision 

  • vision.alt_text - WCAG 2.1 compliant alt text.
  • vision.seo_title - SEO-optimized titles.
  • vision.description - Detailed descriptions.

Translation 

  • translation.general - General purpose translation.
  • translation.technical - Technical documentation.
  • translation.marketing - Marketing copy.

Completion 

  • completion.rule_generation - TYPO3 contexts rules.
  • completion.content_summary - Content summarization.
  • completion.seo_meta - SEO meta descriptions.

Embedding 

  • embedding.semantic_search - Semantic search configuration.

Testing 

Unit tests 

Run feature service tests
# Run all unit tests
Build/Scripts/runTests.sh -s unit

# Alternative: Via Composer script
composer ci:test:php:unit
Copied!

Mocking services 

Example: Mocking feature services in tests
use Netresearch\NrLlm\Service\Feature\VisionService;
use PHPUnit\Framework\TestCase;

class YourServiceTest extends TestCase
{
    public function testImageEnhancement(): void
    {
        $visionMock = $this->createMock(VisionService::class);
        $visionMock->method('generateAltText')
            ->willReturn('Test alt text');

        $service = new YourService($visionMock);
        $result = $service->enhanceImage('test.jpg');

        $this->assertEquals('Test alt text', $result['alt']);
    }
}
Copied!

Performance 

Caching 

  • Embeddings: 24h cache (deterministic).
  • Vision: Short cache (subjective).
  • Translation: Medium cache (context-dependent).
  • Completion: Case-by-case basis.

Batch processing 

Use batch methods for better performance:

Batch processing example
// Good: Single request for multiple images
$altTexts = $visionService->generateAltText($imageUrls);

// Bad: Multiple individual requests
foreach ($imageUrls as $url) {
    $altText = $visionService->generateAltText($url);
}
Copied!

Configuration 

Custom prompts 

Override default prompts via database or configuration:

Custom prompt template in database
INSERT INTO tx_nrllm_prompts (
    identifier,
    title,
    feature,
    system_prompt,
    user_prompt_template,
    temperature,
    max_tokens,
    is_active
) VALUES (
    'custom.vision.alt_text',
    'Custom Alt Text',
    'vision',
    'Custom system prompt...',
    'Custom user prompt with {{image_url}}',
    0.5,
    100,
    1
);
Copied!

Service options 

All services accept configuration options:

Service options example
$result = $completionService->complete(
    prompt: 'Generate text',
    options: [
        'temperature' => 0.7,
        'max_tokens' => 1000,
        'top_p' => 0.9,
        'frequency_penalty' => 0.0,
        'presence_penalty' => 0.0,
        'response_format' => 'json',
        'system_prompt' => 'Custom instructions',
        'stop_sequences' => ['\n\n', 'END'],
    ]
);
Copied!

Extension integration examples 

rte-ckeditor-image 

Example: CKEditor image integration
use Netresearch\NrLlm\Service\Feature\VisionService;

class ImageAiService
{
    public function __construct(
        private readonly VisionService $visionService
    ) {}

    public function enhanceImage(FileReference $file): array
    {
        $url = $file->getPublicUrl();
        return [
            'alt' => $this->visionService->generateAltText($url),
            'title' => $this->visionService->generateTitle($url),
        ];
    }
}
Copied!

textdb 

Example: textdb translation integration
use Netresearch\NrLlm\Service\Feature\TranslationService;
use Netresearch\NrLlm\Service\Feature\EmbeddingService;

class AiTranslationService
{
    public function __construct(
        private readonly TranslationService $translationService,
        private readonly EmbeddingService $embeddingService
    ) {}

    public function suggestTranslation(string $text, string $lang): array
    {
        return [
            'translation' => $this->translationService->translate($text, $lang),
            'similar' => $this->findSimilar($text),
        ];
    }
}
Copied!

contexts 

Example: Contexts rule generation
use Netresearch\NrLlm\Service\Feature\CompletionService;

class RuleGeneratorService
{
    public function __construct(
        private readonly CompletionService $completionService
    ) {}

    public function generateRule(string $description): ?array
    {
        return $this->completionService->completeJson(
            "Generate TYPO3 context rule: $description",
            ['temperature' => 0.2]
        );
    }
}
Copied!

File structure 

Feature services file structure
nr-llm/
├── Classes/
│   ├── Domain/
│   │   └── Model/
│   │       ├── CompletionResponse.php
│   │       ├── VisionResponse.php
│   │       ├── TranslationResult.php
│   │       ├── EmbeddingResponse.php
│   │       ├── UsageStatistics.php
│   │       └── RenderedPrompt.php
│   ├── Service/
│   │   └── Feature/
│   │       ├── CompletionService.php
│   │       ├── VisionService.php
│   │       ├── EmbeddingService.php
│   │       └── TranslationService.php
│   └── Exception/
│       └── InvalidArgumentException.php
├── Configuration/
│   └── Services.yaml
├── Resources/
│   └── Private/
│       └── Data/
│           └── DefaultPrompts.php
└── Tests/
    └── Unit/
        └── Service/
            └── Feature/
                ├── CompletionServiceTest.php
                ├── VisionServiceTest.php
                └── EmbeddingServiceTest.php
Copied!

Requirements 

  • TYPO3 v13.4+.
  • PHP 8.2+.
  • nr-llm core extension (LlmServiceManager).

API reference 

Complete API reference for the TYPO3 LLM extension.

API stability 

Which classes the semantic-versioning promise covers, and what it promises. The authority is the marker in each class-level docblock, not this page and not the DI container visibility (ADR-127).

The three markers 

@api — call it 

Classes and interfaces you call: the feature services (CompletionServiceInterface and friends), LlmServiceManager, the option classes, the response and value objects they accept and return, and the typed exceptions they throw. Within a major version:

  • no class or method is removed,
  • no method signature changes incompatibly,
  • documented behaviour does not break.

Everything a marked method's signature mentions is itself @api — you never receive an object you are not allowed to rely on.

@api Extension point — implement it 

Interfaces and attributes third parties implement: ToolInterface, GuardrailInterface, ProviderInterface and the capability interfaces, TranslatorInterface, SearchBackendInterface, the preset/evaluation providers, the middleware contracts, and the #[AsLlmProvider] / #[AsTranslator] attributes. These carry a stricter promise, forced by the direction of implementation: no new abstract member within a major version — adding one would break every existing implementation, not just callers.

@internal — hands off 

Everything else: backend controllers, dashboard widgets, hooks, upgrade wizards, console commands, DI compiler passes, TCA form elements, Extbase repositories and the setup wizard. These may change or disappear in any release, including patch releases. PHPStan and modern IDEs warn when code outside this package touches them.

What is out of contract 

  • Subclassing internals: protected members of @api classes are not part of the promise. Extend via the extension points, not inheritance.
  • Constructor signatures of @api services: obtain them from the DI container, never via new. Constructing option/value objects directly is fine — their constructors are part of the signature promise.
  • Anything reached by reflection or by reading private state.

The snapshot below records the constructor a caller reaches with new, including the service ones that are out of contract here. That is deliberate: no mechanical rule separates a value object a consumer builds with new from a service it only ever injects, and a new required argument on the former breaks callers exactly as a deleted method would. Recording them costs a service-wiring change one snapshot regeneration; recording none of them let a widened constructor through the gate in silence.

The constructor is also the one member that is not recorded declared-only. Every other member has to be, because inherited TYPO3 core members differ between 13.4 and 14.x — but new Foo(...) binds to whatever constructor Foo inherits, so a declared-only rule left four Specialized services and two ProviderResponseException subclasses with no constructor line at all, and a required argument added to their shared base moved nothing. A constructor is therefore taken from the nearest declaring class whenever that class is inside Netresearch\NrLlm.

Two cases still carry no constructor(...) line, and both are intended:

  • The constructor is inherited from outside this repository — TYPO3 core's AbstractEntity, \RuntimeException. That signature is not ours and does differ across the version matrix, so recording it would make the snapshot depend on which matrix cell rendered it.
  • There is no public constructor. A value object with a private __construct and static factories is reached through the factories, which the snapshot records as methods.

Today that is 70 of the 97 @api classes with a constructor line.

Enforcement 

The rendered @api surface is frozen in Tests/Unit/Api/api-surface.txt: an unintended signature change fails CI before review, and the same test asserts the closure rule: every Netresearch\NrLlm type an @api method or property signature mentions is itself @api. An intended change updates the snapshot in the same PR — the diff is the review artifact.

Constructor parameter types are outside the closure rule. A DI-built service is handed its collaborators by the container, so its constructor names internals by design; the line is still frozen — widening it is a breaking diff — but the types on it are not a promise that you may call them.

The failure is classified, because "different" makes a new value object read like a deleted method. An additive diff (a new class, method, property, constant or enum case) is regenerated and noted under ### Added. A breaking one — anything removed or changed, a widened constructor included — is a decision, and the failure message says so and points at Deprecation and removal policy.

How something leaves the surface again is Deprecation and removal policy. Which TYPO3 and PHP versions the promise is made on is Support matrix.

Versioning during 0.x 

While the extension is pre-1.0, the promise applies one level down, as is conventional: minor releases (0.N → 0.N+1) may break, and do so only with a CHANGELOG entry under a BREAKING heading; patch releases do not break. From 1.0.0 on the promise applies as written above.

Deprecation and removal policy 

ADR-127 says which classes the semver promise covers. This page says how something leaves that surface again.

The rule from 1.0 

Nothing marked @api is removed, renamed or narrowed without a deprecation first:

  1. The member ships as @deprecated in a released minor (1.N.0).
  2. It keeps working, unchanged, through at least one further minor line — deprecated in 1.N.0 means still present and still working in 1.N+1.0.
  3. Only then may it go, and only in the next major (2.0.0). A minor release never removes @api.

"Narrowed" includes a widened constructor: a new required argument on a class consumers build with new breaks them exactly as a deleted method would. The API snapshot records constructors for that reason.

During 0.x 

The notice period starts at 1.0. While the extension is pre-1.0, minor releases may break with a CHANGELOG entry under a BREAKING heading — see API stability. Deprecations shipped during 0.x are listed below and are the first candidates for removal at 1.0; the ones marked retained are not, and say why.

What a deprecation must carry 

  • @deprecated since X.Y.0 — <what to call instead> in the member's docblock. The since version is the evidence for the notice period.
  • A ### Deprecated entry in CHANGELOG.md naming the replacement.
  • A row in the inventory below.

What enforces what 

Half of this policy is a gate and half is a review duty. The difference matters, so it is written down rather than implied.

Rule Enforced by Mechanical
A removed @api member cannot land unnoticed Tests/Unit/Api/ApiSurfaceSnapshotTest — the rendered surface is frozen in api-surface.txt and a removal is classified breaking, which forces an explicit snapshot change in the same pull request yes
A changed signature — including a widened constructor — cannot land unnoticed the same test; constructors are part of the rendered surface yes
Every @deprecated member of an @api class has a written migration Tests/Unit/Api/DeprecationInventoryTest — the member needs a row between the inventory markers and a non-empty "Use instead" cell. It reads the docblocks for all five shapes a deprecation takes: method, constant, public property, enum case and the type itself yes
The inventory cannot keep listing what the code no longer deprecates the same test, in the other direction yes
The notice period itself — deprecated for at least one minor line before removal review. Nothing in the repository knows in which release a docblock tag first appeared; the since version is the only evidence, and a reviewer has to read it no
The ### Deprecated CHANGELOG entry review. Build/Scripts/check-changelog-unreleased.php refuses a [Unreleased] section that repeats itself; it does not require any particular entry to be present no

Currently deprecated 

Everything @deprecated on an @api class today, with the call that replaces it. The two directions of this table are asserted against the docblocks, so it is complete by construction.

Member Since Use instead
Model::getCapabilities() 0.8.0 getCapabilitySet()
Model::getCapabilitiesArray() 0.8.0 getCapabilitySet()->toStringList(). The typed list deduplicates and drops unknown tokens; the legacy accessor preserves both.
Model::getCapabilitiesAsEnums() 0.8.0 getCapabilitySet()->capabilities
Model::setCapabilities() 0.8.0 setCapabilitySet() with a typed set — it validates against the capability enum and deduplicates.
Model::setCapabilitiesArray() 0.8.0 setCapabilitySet(CapabilitySet::fromArray(...))
Model::hasCapability() 0.8.0 getCapabilitySet()->has() — accepts both the enum and the legacy string form.
Model::addCapability() 0.8.0 setCapabilitySet(getCapabilitySet()->with(...))
Model::removeCapability() 0.8.0 setCapabilitySet(getCapabilitySet()->without(...))
Provider::getOptions() 0.8.0 getOptionsObject(). Retained — Extbase hydrates the entity through this getter/setter pair.
Provider::getOptionsArray() 0.8.0 getOptionsObject()
Provider::setOptions() 0.8.0 setOptionsObject(). Retained — Extbase property mapping.
Provider::setOptionsArray() 0.8.0 setOptionsObject()
LlmConfiguration::SELECTION_MODE_FIXED the ModelSelectionMode enum, case FIXED
LlmConfiguration::SELECTION_MODE_CRITERIA the ModelSelectionMode enum, case CRITERIA
LlmConfiguration::getModelSelectionCriteria() 0.8.0 getModelSelectionCriteriaDTO(). Retained — Extbase property mapping.
LlmConfiguration::setModelSelectionCriteria() 0.8.0 setModelSelectionCriteriaDTO(). Retained — Extbase property mapping.
LlmConfiguration::getOptions() 0.8.0 getOptionsArray(). The options field carries provider-specific extras, so the typed surface stops at the array. Retained — Extbase property mapping.
LlmConfiguration::setOptions() 0.8.0 setOptionsArray(). Retained — Extbase property mapping.
LlmConfiguration::getFallbackChain() 0.8.0 getFallbackChainDTO(). Retained — Extbase property mapping.
LlmConfiguration::setFallbackChain() 0.8.0 setFallbackChainDTO(). Retained — Extbase property mapping.

Retained means the member is deprecated for application code but cannot be deleted: Extbase hydrates the entity through the raw getter/setter pair, so removing it would break persistence rather than only callers. Those rows stay past 1.0 and past 2.0. The two SELECTION_MODE_* constants predate the since convention; they carry no version because none was recorded, not because none applies.

Support matrix 

Which TYPO3 and PHP versions nr_llm 1.x runs on, and when each line ends.

The versions 

TYPO3 nr_llm 1.x Regular maintenance ends ELTS ends
13.4 LTS supported 2027-12-31 2030-12-31
14.3 LTS supported 2029-06-30 2032-06-30

TYPO3 14.0–14.2 are not supported. They are sprint releases, not the LTS, and the extension requires ^14.3. The TER depends range in ext_emconf.php reads 13.4.0-14.99.99 only because that format cannot express a gap.

PHP nr_llm 1.x Upstream security support ends
8.2 supported 2026-12-31
8.3 supported 2027-12-31
8.4 supported 2028-12-31
8.5 supported 2029-12-31

When a line ends 

A version stays supported while its upstream is in regular maintenance. ELTS is a paid TYPO3 Association product and nr_llm does not track it.

Dropping a version is a minor bump, never a patch, and it happens in the first minor release after the upstream date above — not on the date itself. Dropping one raises the floor in composer.json, ext_emconf.php and the CI matrix together, and this page moves in the same change.

Further places state the range in prose and are not asserted against anything. The ones known today: the README.md badges and its Requirements list, Installation, Introduction, Documentation/Developer/FeatureServices/Index.rst (its requirements list), Documentation/Testing/CiConfiguration.rst (a hand-copied excerpt of the CI matrix) and Documentation/Developer/IntegrationGuide.rst (the TER constraint in its ext_emconf.php example). A floor change has to edit them by hand — a green unit suite says nothing about them, and the list is what has been found, not a proof that nothing else repeats the range.

BASELINE.md's "Multi-version CI" row is the exception: it is checked, by Tests/Unit/BaselineConsistencyTest, against the ci: job's TYPO3 matrix.

PHP 8.2 is the nearest edge: its upstream security support ends 2026-12-31, so it is the next floor to rise.

Declared constraints 

The literals below are the ones the build actually uses. They are asserted against composer.json, ext_emconf.php and .github/workflows/ci.yml by Tests/Unit/VersionConsistencyTest — a matrix that drifts from what CI runs fails the unit suite rather than misleading a reader.

composer typo3/cms-core

^13.4 || ^14.3

composer php

^8.2

ext_emconf typo3

13.4.0-14.99.99

ext_emconf php

8.2.0-8.99.99

ci typo3-versions

^13.4, ^14.3

ci php-versions

8.2, 8.3, 8.4, 8.5

The CI values are the union across every matrix in ci.yml. Single cells there — the merge queue's reduced PHP set, the MariaDB functional leg — are subsets by design and do not narrow what is supported.

LlmServiceManager 

The central service for all LLM operations.

class LlmServiceManager
Fully qualified name
\Netresearch\NrLlm\Service\LlmServiceManager

Orchestrates LLM providers and provides unified API access.

chat ( array $messages, ?ChatOptions $options = null) : CompletionResponse

Execute a chat completion request.

param array $messages

Array of message objects with 'role' and 'content' keys

param ChatOptions|null $options

Optional config

Message Format:

Chat message format
$messages = [
    ['role' => 'system', 'content' => '...'],
    ['role' => 'user', 'content' => 'Hello!'],
    ['role' => 'assistant', 'content' => 'Hi!'],
    ['role' => 'user', 'content' => 'How are you?'],
];
Copied!
Returns

CompletionResponse

complete ( string $prompt, ?ChatOptions $options = null) : CompletionResponse

Simple completion from a single prompt.

param string $prompt

The prompt text

param ChatOptions|null $options

Optional config

Returns

CompletionResponse

embed ( string|array $input, ?EmbeddingOptions $options = null) : EmbeddingResponse

Generate embeddings for text.

param string|array $input

Single text or array of texts

param EmbeddingOptions|null $options

Optional configuration

Returns

EmbeddingResponse

embedForConfiguration ( string|array $input, LlmConfiguration $configuration, ?EmbeddingOptions $options = null) : EmbeddingResponse

Generate embeddings against a specific LLM configuration.

Resolves the adapter from the configuration's model (vault key + model + pricing) and runs through the middleware pipeline, so per-configuration budgets and cost attribution apply. Per-call options take precedence over the configuration's stored defaults: an options model overrides the configuration's model id. Throws UnsupportedFeatureException when the configuration's provider does not support embeddings.

param string|array $input

Single text or array of texts

param LlmConfiguration $configuration

The configuration record to resolve provider/model from

param EmbeddingOptions|null $options

Optional configuration

Returns

EmbeddingResponse

vision ( array $content, ?VisionOptions $options = null) : VisionResponse

Analyze an image with vision capabilities.

param array $content

Array of content parts (text and image_url entries)

param VisionOptions|null $options

Optional configuration

Returns

VisionResponse

streamChat ( array $messages, ?ChatOptions $options = null) : Generator

Stream a chat completion response.

param array $messages

Array of message objects

param ChatOptions|null $options

Optional config

Returns

Generator yielding string chunks

chatWithTools ( array $messages, array $tools, ?ToolOptions $options = null) : CompletionResponse

Chat with tool/function calling capability.

param array $messages

Array of message objects

param array $tools

Array of tool definitions

param ToolOptions|null $options

Optional config

Returns

CompletionResponse with tool calls

getProvider ( ?string $identifier = null) : ProviderInterface

Get a specific provider by identifier. An explicit identifier is required; passing null throws ProviderException (code 4867297358). To select a provider without naming one, pin it per call via the options object's provider field, or configure an active default Configuration in the backend module (see ADR-034).

param string|null $identifier

Provider identifier (openai, claude, gemini); null is rejected

throws

ProviderException

Returns

ProviderInterface

getAvailableProviders ( ) : array

Get all configured and available providers.

Returns

array<string, ProviderInterface>

CompletionService 

class CompletionService
Fully qualified name
\Netresearch\NrLlm\Service\Feature\CompletionService

High-level text completion with format control.

complete ( string $prompt, ?ChatOptions $options = null) : CompletionResponse

Standard text completion.

param string $prompt

The prompt text

param ?ChatOptions $options

Optional configuration

Returns

CompletionResponse

completeJson ( string $prompt, ?ChatOptions $options = null) : array

Completion with JSON output parsing.

param string $prompt

The prompt text

param ?ChatOptions $options

Optional configuration

Returns

array Parsed JSON data

completeMarkdown ( string $prompt, ?ChatOptions $options = null) : string

Completion with markdown formatting.

param string $prompt

The prompt text

param ?ChatOptions $options

Optional configuration

Returns

string Markdown formatted text

completeFactual ( string $prompt, ?ChatOptions $options = null) : CompletionResponse

Low-creativity completion for factual responses.

param string $prompt

The prompt text

param ?ChatOptions $options

Optional configuration (temperature defaults to 0.1)

Returns

CompletionResponse

completeCreative ( string $prompt, ?ChatOptions $options = null) : CompletionResponse

High-creativity completion for creative content.

param string $prompt

The prompt text

param ?ChatOptions $options

Optional configuration (temperature defaults to 1.2)

Returns

CompletionResponse

completeStructured ( string $prompt, array $schema, ?ChatOptions $options = null) : array

Completion validated against a JSON schema from the strict named subset (ADR-126): type, enum, const, pattern, lengths, numeric bounds, items, properties/ required/additionalProperties and the combinators — $ref is deliberately out. The schema is pre-flighted BEFORE the first provider call (an out-of-subset schema throws code 1784500003 instead of costing paid requests), enforced provider-natively where the provider can (ADR-128), validated strictly on the response, and repaired with one controlled round-trip on a mismatch.

param string $prompt

The prompt text

param array $schema

JSON schema inside the strict subset

param ?ChatOptions $options

Optional configuration

throws

InvalidArgumentException on an out-of-subset schema (1784500003) or when the response still fails the schema after one repair attempt (1784500001)

Returns

array The decoded, schema-valid JSON payload

Every method above also exists as a *ForConfiguration() variant taking a persisted LlmConfiguration as its second argument — the call then runs with that configuration's provider, model, options and skills instead of the system default.

EmbeddingService 

class EmbeddingService
Fully qualified name
\Netresearch\NrLlm\Service\Feature\EmbeddingService

Text-to-vector conversion with caching and similarity operations.

embed ( string $text, ?EmbeddingOptions $options = null) : array

Generate embedding vector for text (cached).

param string $text

The text to embed

param ?EmbeddingOptions $options

Optional config

Returns

array<float> Vector representation

embedFull ( string $text, ?EmbeddingOptions $options = null) : EmbeddingResponse

Generate embedding with full response metadata.

param string $text

The text to embed

param ?EmbeddingOptions $options

Optional config

Returns

EmbeddingResponse

embedBatch ( array $texts, ?EmbeddingOptions $options = null) : array

Generate embeddings for multiple texts.

param array $texts

Array of texts

param ?EmbeddingOptions $options

Optional config

Returns

array<array<float>> Array of vectors

embedForConfiguration ( string $text, LlmConfiguration $configuration, ?EmbeddingOptions $options = null) : array

Generate embedding vector for text against a specific LLM configuration, so the configuration's provider/model drive the call and per-configuration budgets and cost attribution apply.

param string $text

The text to embed

param LlmConfiguration $configuration

The configuration record to resolve provider/model from

param ?EmbeddingOptions $options

Optional config

Returns

array<float> Vector representation

embedBatchForConfiguration ( array $texts, LlmConfiguration $configuration, ?EmbeddingOptions $options = null) : array

Generate embeddings for multiple texts against a specific LLM configuration in a single provider call.

param array $texts

Array of texts

param LlmConfiguration $configuration

The configuration record to resolve provider/model from

param ?EmbeddingOptions $options

Optional config

Returns

array<array<float>> Array of vectors

cosineSimilarity ( array $a, array $b) : float

Calculate cosine similarity between two vectors.

param array $a

First vector

param array $b

Second vector

Returns

float Similarity score (-1 to 1)

findMostSimilar ( array $queryVector, array $candidates, int $topK = 5) : array

Find most similar vectors from candidates.

param array $queryVector

The query vector

param array $candidates

Array of candidate vectors

param int $topK

Number of results to return

Returns

array Sorted by similarity (highest first)

pairwiseSimilarities ( array $vectors) : array

Calculate pairwise similarities between all vectors.

Returns a 2D matrix where each cell [i][j] contains the cosine similarity between vectors i and j. Diagonal values are always 1.0.

param array $vectors

Array of embedding vectors

Returns

array 2D array of similarity scores

normalize ( array $vector) : array

Normalize a vector to unit length.

param array $vector

The vector to normalize

Returns

array Normalized vector

VisionService 

class VisionService
Fully qualified name
\Netresearch\NrLlm\Service\Feature\VisionService

Image analysis with specialized prompts.

generateAltText(string|array $imageUrl, ?VisionOptions $options = null): string|array ( )

Generate WCAG-compliant alt text.

Optimized for screen readers and WCAG 2.1 Level AA compliance. Output is concise (under 125 characters) and focuses on essential information.

param string|array $imageUrl

URL, local path, or array of URLs for batch processing

param VisionOptions|null $options

Vision options (defaults: maxTokens=100, temperature=0.5)

Returns

string|array Alt text or array of alt texts for batch input

generateTitle(string|array $imageUrl, ?VisionOptions $options = null): string|array ( )

Generate SEO-optimized image title.

Creates compelling, keyword-rich titles under 60 characters for improved search rankings.

param string|array $imageUrl

URL, local path, or array of URLs for batch processing

param VisionOptions|null $options

Vision options (defaults: maxTokens=50, temperature=0.7)

Returns

string|array Title or array of titles for batch input

generateDescription(string|array $imageUrl, ?VisionOptions $options = null): string|array ( )

Generate detailed image description.

Provides comprehensive analysis including subjects, setting, colors, mood, composition, and notable details.

param string|array $imageUrl

URL, local path, or array of URLs for batch processing

param VisionOptions|null $options

Vision options (defaults: maxTokens=500, temperature=0.7)

Returns

string|array Description or array of descriptions for batch input

analyzeImage(string|array $imageUrl, string $customPrompt, ?VisionOptions $options = null): string|array ( )

Custom image analysis with specific prompt.

param string|array $imageUrl

URL, local path, or array of URLs for batch processing

param string $customPrompt

Custom analysis prompt

param VisionOptions|null $options

Vision options

Returns

string|array Analysis result or array of results for batch input

analyzeImageFull ( string $imageUrl, string $prompt, ?VisionOptions $options = null) : VisionResponse

Full image analysis returning complete response with usage statistics.

Returns a VisionResponse with metadata and usage data, unlike the other methods which return plain text.

param string $imageUrl

Image URL or base64 data URI

param string $prompt

Analysis prompt

param VisionOptions|null $options

Vision options

throws

InvalidArgumentException If image URL is invalid

Returns

VisionResponse Complete response with usage data

DocumentAnalysisService 

class DocumentAnalysisService
Fully qualified name
\Netresearch\NrLlm\Specialized\Document\DocumentAnalysisService

Stateless "understand this document" primitive (ADR-076).

When the resolved provider implements DocumentCapableInterface (Gemini, Claude), the PDF is ingested natively as a Base64 document block in a single chat call — whole-document reasoning. Otherwise the document is rasterized page-by-page with poppler and each page is read by the vision model; the per-page answers are concatenated with [Page N] markers.

analyzeDocument ( string $pdf, string $prompt, ?ChatOptions $options = null) : DocumentAnalysisResult

Analyze a PDF with a custom prompt.

param string $pdf

Raw PDF bytes (must start with the %PDF- header)

param string $prompt

Analysis prompt, applied to the whole document on the native path and to each page on the fallback

param ChatOptions|null $options

Chat options; provider, model, maxTokens, temperature and the budget attribution fields (beUserUid, plannedCost) are passed through to whichever path runs

throws

UnsupportedFormatException when the bytes are not a PDF

throws

ProviderException when no provider is resolvable (no explicit provider and no active default configuration) — the same error a plain chat() call raises

throws

ServiceUnavailableException when the provider lacks native PDF support and poppler is not installed

throws

PdfRasterizationException when rasterization itself fails

Returns

DocumentAnalysisResult with the answer text, the model/provider that produced it, whether the native document path was used, and the rasterized page count

Usage 

Analyzing a PDF
use Netresearch\NrLlm\Service\Option\ChatOptions;
use Netresearch\NrLlm\Specialized\Document\DocumentAnalysisService;

public function __construct(
    private readonly DocumentAnalysisService $documentAnalysis,
) {}

$result = $this->documentAnalysis->analyzeDocument(
    $pdfBytes,
    'List the key obligations defined in this contract.',
    new ChatOptions(maxTokens: 1024),
);

$result->text;                    // the answer
$result->usedNativeDocumentPath;  // true: one whole-document call
$result->rasterizedPageCount;     // pages read on the fallback path
Copied!

Optional system dependency: poppler 

The rasterization fallback shells out to the poppler binaries pdftoppm and pdfimages (Debian/Ubuntu package poppler-utils; declared in composer.json suggest). They are only needed when the resolved provider has no native PDF support. Without them, the fallback fails with the typed ServiceUnavailableException (code 1784211009) naming both remedies: install poppler-utils or configure a document-capable provider. The native path never touches poppler.

Degradation policy is the caller's: on the fallback path a failed page fails the call — catch and retry (or degrade) in the consumer.

PdfRasterizerInterface 

class PdfRasterizerInterface
Fully qualified name
\Netresearch\NrLlm\Specialized\Document\PdfRasterizerInterface

Rasterizes PDF pages to PNG blobs. The default implementation is the poppler-backed PopplerPdfRenderer; substitute it by aliasing the interface to another class in your Services.yaml.

imagePages ( string $absolutePath) : array

1-based page numbers carrying at least one embedded raster image.

renderPage ( string $absolutePath, int $page) : string

PNG bytes of one rasterized page.

renderDocument ( string $absolutePath) : array

PNG bytes per 1-based page number for the whole document.

isAvailable ( ) : bool

Whether the system binaries are present.

TranslationService 

class TranslationService
Fully qualified name
\Netresearch\NrLlm\Service\Feature\TranslationService

Language translation with quality control.

translate ( string $text, string $targetLanguage, ?string $sourceLanguage = null, ?TranslationOptions $options = null) : TranslationResult

Translate text to target language.

param string $text

Text to translate

param string $targetLanguage

Target language code (e.g., 'de', 'fr')

param string|null $sourceLanguage

Source language code (auto-detected if null)

param TranslationOptions|null $options

Translation options

TranslationOptions fields:

  • formality: 'formal', 'informal', 'default'
  • domain: 'technical', 'legal', 'medical', 'marketing', 'general'
  • glossary: array of term translations
  • preserve_formatting: bool
  • provider, model: pin the provider / model for this call
  • configuration: identifier of a stored LlmConfiguration whose translator is used on the specialized-translator path (translateWithTranslator())
Returns

TranslationResult

translateForConfiguration ( string $text, string $targetLanguage, LlmConfiguration $configuration, ?string $sourceLanguage = null, ?TranslationOptions $options = null) : TranslationResult

Translate with a stored LlmConfiguration's persona/tone.

Unlike translate, this routes through LlmServiceManager::chatWithConfiguration() so the configuration's stored system_prompt, model, provider and skills apply. translate() supplies its own system message and therefore short-circuits MessageShaper::applySystemPrompt(), so a configuration's system_prompt never reaches the model on that path. Here the translation task and constraints (target/source language, formality, glossary, "output only the translation") are layered into the user message instead, keeping the configuration's system_prompt as the system message.

Mirrors chatWithToolsForConfiguration() and embedForConfiguration().

param string $text

Text to translate

param string $targetLanguage

Target language code (e.g., 'de', 'fr')

param LlmConfiguration $configuration

The configuration whose persona/model drive the call

param string|null $sourceLanguage

Source language code (auto-detected if null)

param TranslationOptions|null $options

Translation options; temperature, max_tokens and model override the configuration's stored defaults when set. The provider field is ignored — the configuration selects the provider.

Returns

TranslationResult

translateBatch ( array $texts, string $targetLanguage, ?string $sourceLanguage = null, ?TranslationOptions $options = null) : array

Translate multiple texts.

param array $texts

Array of texts

param string $targetLanguage

Target language code

param string|null $sourceLanguage

Source language code (auto-detected if null)

param TranslationOptions|null $options

Translation options

Returns

array<TranslationResult>

detectLanguage ( string $text, ?TranslationOptions $options = null) : string

Detect the language of text.

param string $text

Text to analyze

param TranslationOptions|null $options

Translation options

Returns

string Language code (ISO 639-1)

scoreTranslationQuality ( string $sourceText, string $translatedText, string $targetLanguage, ?TranslationOptions $options = null) : float

Score translation quality.

param string $sourceText

Original text

param string $translatedText

Translated text

param string $targetLanguage

Target language code

param TranslationOptions|null $options

Translation options

Returns

float Quality score (0.0 to 1.0)

Editor localization menu: translate with a chosen configuration 

An editor localization menu that lets the user pick between configurations with different tones/prompts resolves the chosen LlmConfiguration and hands it to translationservice-translateforconfiguration — the configuration's system_prompt (persona/tone) and model then drive the call, while the translation task itself is layered in automatically.

use Netresearch\NrLlm\Service\Feature\TranslationServiceInterface;
use Netresearch\NrLlm\Service\LlmConfigurationServiceInterface;

public function __construct(
    private readonly TranslationServiceInterface $translationService,
    private readonly LlmConfigurationServiceInterface $configurationService,
) {}

// 1. Offer the configurations the current backend user may use.
$choices = $this->configurationService->getAccessibleConfigurations();

// 2. Resolve the one the editor selected in the menu.
$configuration = $this->configurationService->getConfiguration($selectedIdentifier);

// 3. Translate with that configuration's persona/tone and model.
$result = $this->translationService->translateForConfiguration(
    $text,
    'de',
    $configuration,
    // $sourceLanguage: null => auto-detected
);
Copied!

ToolCallingService 

class ToolCallingService
Fully qualified name
\Netresearch\NrLlm\Service\Feature\ToolCallingService

Tool-calling chat completion. Depend on ToolCallingServiceInterface rather than this class (or the service manager) — the interface exposes exactly the tool-calling capability, so consumer test doubles stay two methods small (ADR-051).

When no beUserUid is set on the options, the active backend user is resolved and populated automatically so per-user budget enforcement applies.

chatWithTools ( array $messages, array $tools, ?ToolOptions $options = null) : CompletionResponse

Chat completion with tool calling. The provider is resolved from the options (or the extension's default); the configuration is a model-less transient one.

param array $messages

list of ChatMessage (or legacy {role, content} arrays)

param array $tools

list of ToolSpec (or legacy OpenAI-wire arrays)

param ?ToolOptions $options

Tool choice, provider/model pin, budget fields

Returns

CompletionResponse (toolCalls carries requested calls)

chatWithToolsForConfiguration ( array $messages, array $tools, LlmConfiguration $configuration, ?ToolOptions $options = null) : CompletionResponse

Chat completion with tool calling against a specific LLM configuration — the adapter is resolved from the configuration's model (vault key, model, pricing), so budget and usage middleware record real cost. Prefer this entry point when a database-backed configuration exists.

param array $messages

list of ChatMessage (or legacy {role, content} arrays)

param array $tools

list of ToolSpec (or legacy OpenAI-wire arrays)

param LlmConfiguration $configuration

The configuration to run on

param ?ToolOptions $options

Tool choice and budget fields

Returns

CompletionResponse (toolCalls carries requested calls)

Usage 

use Netresearch\NrLlm\Domain\ValueObject\ChatMessage;
use Netresearch\NrLlm\Domain\ValueObject\ToolSpec;
use Netresearch\NrLlm\Service\Feature\ToolCallingServiceInterface;
use Netresearch\NrLlm\Service\Option\ToolOptions;

final readonly class WeatherAgent
{
    public function __construct(
        private ToolCallingServiceInterface $toolCalling,
    ) {}

    public function ask(string $question): string
    {
        $response = $this->toolCalling->chatWithTools(
            [ChatMessage::user($question)],
            [ToolSpec::function(
                'get_weather',
                'Get the current weather for a location.',
                [
                    'type' => 'object',
                    'properties' => ['location' => ['type' => 'string']],
                    'required' => ['location'],
                ],
            )],
            ToolOptions::auto(),
        );

        // Dispatch $response->toolCalls and continue the conversation —
        // see :ref:`tool-calling` for the full loop.
        return $response->content;
    }
}
Copied!

KeywordSearch 

interface KeywordSearchInterface
Fully qualified name
\Netresearch\NrLlm\Service\Retrieval\KeywordSearchInterface

Public keyword-search facade over the site-search retrieval cascade (ADR-071: Public keyword-search facade over the retrieval cascade). Searches the first available backend (Solr, ke_search, indexed_search, database fallback), always filtered public-only — hits are what the anonymous visitor could read.

Input is clamped, never rejected, and any backend failure degrades to an empty result: the facade never throws.

Run a public-only keyword search. The query is trimmed and truncated to 200 characters; a query shorter than 2 characters returns an empty list. The limit is clamped to 1–20; a negative language id is clamped to 0. Hits are deduplicated by URL and capped at the limit.

param string $query

Free-text query

param int $limit

Maximum number of hits (clamped to 1–20)

param ?int $languageId

sys_language uid; null means default (0)

Returns

list<KeywordHit> — empty when nothing matched, the query is too short, or no backend is available

isAvailable ( ) : bool

Whether at least one search backend of this variant can answer right now. Never throws.

class KeywordHit
Fully qualified name
\Netresearch\NrLlm\Service\Retrieval\KeywordHit

One keyword-search hit. Final readonly DTO.

sourceId

string — Stable source id; format is backend-internal.

title

string — Result title.

url

string — Public URL of the hit (may be empty when the backend cannot resolve one).

excerpt

string — Short indexed-content excerpt.

languageId

int — sys_language uid the hit belongs to.

score

?float — Backend-native relevance score; not comparable across backends; null when the backend reports none.

pageUid

?int — Page uid when the answering backend can resolve the hit to a page, null otherwise.

Service variants 

Two container registrations exist (ADR-071: Public keyword-search facade over the retrieval cascade):

  • KeywordSearchInterface — the full cascade including the database LIKE fallback. Wire it via constructor type hint or resolve it from the container.
  • nr_llm.keyword_search.index_backed — a named variant that excludes the fallback tier. Use it when "index unavailable" must yield an empty result instead of LIKE hits (e.g. hybrid dense+sparse fusion). Its isAvailable() answers for index-backed engines only.

Usage 

use Netresearch\NrLlm\Service\Retrieval\KeywordSearchInterface;

final class PageFinder
{
    public function __construct(
        private readonly KeywordSearchInterface $keywordSearch,
    ) {}

    public function findCandidates(string $topic): array
    {
        if (!$this->keywordSearch->isAvailable()) {
            return [];
        }

        return $this->keywordSearch->search($topic, 10);
    }
}
Copied!

Wiring the index-backed-only variant:

Vendor\Ext\Search\SparseArm:
  arguments:
    $keywordSearch: '@nr_llm.keyword_search.index_backed'
Copied!

ReciprocalRankFusion 

class ReciprocalRankFusion
Fully qualified name
\Netresearch\NrLlm\Service\Retrieval\ReciprocalRankFusion

Reciprocal Rank Fusion (Cormack et al., 2009) for hybrid retrieval (ADR-074: Reciprocal Rank Fusion as a hosted utility). Fuses several ranked key lists using only per-list rank, never score magnitude — so it combines rankings on incomparable score scales (dense cosine similarity, sparse BM25) without any normalization.

Final readonly class, newable: construct it with new, it is not a DI service. nr_llm's own retrieval cascade (ADR-049: RAG site-search tools over installed search indexes) does not call it — it exists for hybrid consumers that fan out to several retrieval arms themselves.

fuse ( array $rankedKeyLists, int $k = 60, array $weights = []) : array

Fuse ranked key lists into one list ordered by descending RRF score. For each key the fused score is Σi weighti / (k + ranki), where ranki is the key's 1-based position in list i; a key absent from a list contributes nothing there. Duplicates within a list are ignored past their first rank. Equal scores keep first-seen order (list 0 before list 1's new keys). A $k below 1 is clamped to 1.

param array $rankedKeyLists

list<list<string>> — each inner list is keys best-first

param int $k

rank-smoothing constant; smaller values let top ranks dominate

param array $weights

list<float> — per-list weight (same index); missing or extra entries default to 1.0

Returns

list<int|string> — fused keys, highest RRF score first. PHP array-key coercion applies: numeric-string keys (e.g. '42') come back as int, so compare fused keys loosely or cast before a strict comparison.

Usage 

use Netresearch\NrLlm\Service\Retrieval\ReciprocalRankFusion;

$denseKeys = ['page:12', 'page:7', 'page:3'];   // embedding arm, best-first
$sparseKeys = ['page:7', 'page:9'];             // keyword arm, best-first

$fused = (new ReciprocalRankFusion())->fuse(
    [$denseKeys, $sparseKeys],
    60,
    [1.0, 0.5],  // trust the dense arm twice as much
);
// ['page:7', ...] — ranked in both arms, so it wins
Copied!

Reranker 

interface RerankerInterface
Fully qualified name
\Netresearch\NrLlm\Service\Rerank\RerankerInterface

Neutral cross-encoder reranking protocol (ADR-075: Neutral cross-encoder reranker protocol): scores retrieval candidates against a query. Candidates go in as plain id/text shapes and scores come back as plain id/score shapes — no consumer DTOs cross the boundary. Consumers own DTO mapping, the ordering merge, the degradation policy on failure, and any score-threshold gate.

The container service is factory-built from the extension configuration: an empty rerankerEndpoint resolves to NullReranker, a configured endpoint to HttpReranker.

rerank ( string $query, array $candidates) : array

Score each (query, candidate text) pair. Returns one entry per scored candidate in input order; an entry the backend failed to score may be omitted — merge by id.

param string $query

The query the candidates are scored against

param array $candidates

list<array{id: string, text: string}>

throws

RerankerException when the reranker backend is unreachable or answers outside the protocol

Returns

list<array{id: string, score: float}>

class HttpReranker
Fully qualified name
\Netresearch\NrLlm\Service\Rerank\HttpReranker

Speaks the cross-encoder sidecar contract (Build/reranker): POST {endpoint}/rerank with {"query", "documents"}, scores returned in input order. Pools above the sidecar's batch cap (128 documents) are split into sequential requests. The score scale is model-specific (default BAAI/bge-reranker-v2-m3).

class NullReranker
Fully qualified name
\Netresearch\NrLlm\Service\Rerank\NullReranker

Selected when no sidecar endpoint is configured. Returns one entry per candidate in input order with a uniform score of 0.0 — no ranking signal, shape-identical to HttpReranker.

class RerankerException
Fully qualified name
\Netresearch\NrLlm\Service\Rerank\Exception\RerankerException

Typed failure of the reranker backend: unreachable endpoint (code 1784750001), non-200 status (1784750002), invalid JSON (1784750003), or a response missing the scores array (1784750004). Implements NrLlmExceptionInterface (ADR-053); the caller decides how to degrade — nr_llm never silently falls back.

Configuration 

Extension configuration keys (nr_llm):

  • rerankerEndpoint — base URL of the cross-encoder sidecar, e.g. http://reranker:8081. Empty (default) disables reranking.
  • rerankerTimeout — request timeout in seconds (default 30; a CPU cross-encoder can be slow for a wide candidate pool).

See Build/reranker/README.md for running the sidecar.

Response objects 

CompletionResponse 

class CompletionResponse
Fully qualified name
\Netresearch\NrLlm\Domain\Model\CompletionResponse

Response from chat/completion operations.

string content

The generated text content.

string model

The model used for generation.

UsageStatistics usage

Token usage statistics.

string finishReason

Why generation stopped: 'stop', 'length', 'content_filter', 'tool_calls'

string provider

The provider identifier.

array|null toolCalls

Tool calls if any were made.

array|null metadata

Provider-specific metadata. Structure varies by provider.

string|null thinking

Thinking/reasoning content from models that support extended thinking (e.g., Claude with thinking enabled).

isComplete ( ) : bool

Check if response finished normally.

wasTruncated ( ) : bool

Check if response hit max_tokens limit.

wasFiltered ( ) : bool

Check if content was filtered.

hasToolCalls ( ) : bool

Check if response contains tool calls.

hasThinking ( ) : bool

Check if response contains thinking/reasoning content.

getText ( ) : string

Alias for content property.

VisionResponse 

class VisionResponse
Fully qualified name
\Netresearch\NrLlm\Domain\Model\VisionResponse

Response from vision/image analysis operations.

string description

The generated image analysis text.

string model

The model used for analysis.

UsageStatistics usage

Token usage statistics.

string provider

The provider identifier.

float|null confidence

Confidence score for the analysis (if available).

array|null detectedObjects

Detected objects in the image (if available).

array|null metadata

Provider-specific metadata.

getText ( ) : string

Get the analysis text. Alias for description property.

getDescription ( ) : string

Alias for description property.

meetsConfidence ( float $threshold) : bool

Check if confidence score meets or exceeds a threshold.

param float $threshold

Minimum confidence value

Returns

bool True if confidence is not null and meets threshold

EmbeddingResponse 

class EmbeddingResponse
Fully qualified name
\Netresearch\NrLlm\Domain\Model\EmbeddingResponse

Response from embedding operations.

array embeddings

Array of embedding vectors.

string model

The model used for embedding.

UsageStatistics usage

Token usage statistics.

string provider

The provider identifier.

getVector ( ) : array

Get the first embedding vector.

static cosineSimilarity ( array $a, array $b)

Calculate cosine similarity between vectors.

returns

float

TranslationResult 

class TranslationResult
Fully qualified name
\Netresearch\NrLlm\Domain\Model\TranslationResult

Response from translation operations.

string translation

The translated text.

string sourceLanguage

Detected or provided source language.

string targetLanguage

The target language.

float confidence

Confidence score (0.0 to 1.0).

UsageStatistics 

class UsageStatistics
Fully qualified name
\Netresearch\NrLlm\Domain\Model\UsageStatistics

Token usage and cost tracking.

int promptTokens

Tokens in the prompt/input.

int completionTokens

Tokens in the completion/output.

int totalTokens

Total tokens used.

float|null estimatedCost

Estimated cost in USD (if available).

Option classes 

ChatOptions 

class ChatOptions
Fully qualified name
\Netresearch\NrLlm\Service\Option\ChatOptions

Typed options for chat operations.

static factual ( )

Create options optimized for factual responses (temperature: 0.1).

returns

ChatOptions

static creative ( )

Create options for creative content (temperature: 1.2).

returns

ChatOptions

static balanced ( )

Create balanced options (temperature: 0.7).

returns

ChatOptions

static json ( )

Create options for JSON output format.

returns

ChatOptions

static code ( )

Create options optimized for code generation.

returns

ChatOptions

withTemperature ( float $temperature) : self

Set temperature (0.0 - 2.0).

withMaxTokens ( int $maxTokens) : self

Set maximum output tokens.

withTopP ( float $topP) : self

Set nucleus sampling parameter.

withFrequencyPenalty ( float $penalty) : self

Set frequency penalty (-2.0 to 2.0).

withPresencePenalty ( float $penalty) : self

Set presence penalty (-2.0 to 2.0).

withSystemPrompt ( string $prompt) : self

Set system prompt.

withResponseFormat ( string $format) : self

Request an output format: text, json or markdown. json activates the provider's native JSON mode on every adapter (ADR-128).

withResponseSchema ( array $schema) : self

Attach a strict-subset JSON schema (ADR-126) the provider should enforce natively where it can. Set automatically by completeStructured(); the local strict validation remains authoritative either way.

withStopSequences ( array $sequences) : self

Set stop sequences the model must not generate past.

withProvider ( string $provider) : self

Set provider (openai, claude, gemini).

withModel ( string $model) : self

Set specific model.

toArray ( ) : array

Convert to array format.

Provider interface 

interface ProviderInterface
Fully qualified name
\Netresearch\NrLlm\Provider\Contract\ProviderInterface

Contract for LLM providers.

getName ( ) : string

Get human-readable provider name.

getIdentifier ( ) : string

Get provider identifier for configuration.

configure ( array $config) : void

Configure the provider with API key and settings.

param array $config

Configuration key-value pairs

isAvailable ( ) : bool

Check if provider is available and configured.

supportsFeature ( string|ModelCapability $feature) : bool

Check if provider supports a specific feature.

chatCompletion ( array $messages, array $options = []) : CompletionResponse

Execute chat completion.

param array $messages

Messages with role and content. Content can be a string (plain text) or an array of content blocks for multimodal input (text, image_url, document).

complete ( string $prompt, array $options = []) : CompletionResponse

Execute simple completion from a prompt.

embeddings ( string|array $input, array $options = []) : EmbeddingResponse

Generate embeddings for text.

getAvailableModels ( ) : array

Get list of available models.

getDefaultModel ( ) : string

Get the default model identifier.

testConnection ( ) : array

Test the connection to the provider.

throws

ProviderConnectionException

Returns

array{success, message, models?}

interface VisionCapableInterface
Fully qualified name
\Netresearch\NrLlm\Provider\Contract\VisionCapableInterface

Contract for providers supporting vision/image analysis.

analyzeImage ( array $content, array $options = []) : VisionResponse

Analyze an image.

param array $content

Array of content parts (text and image_url entries)

param array $options

Optional configuration

Returns

VisionResponse

supportsVision ( ) : bool

Check if vision is supported.

getSupportedImageFormats ( ) : array

Get supported image formats.

getMaxImageSize ( ) : int

Get maximum image size in bytes.

interface StreamingCapableInterface
Fully qualified name
\Netresearch\NrLlm\Provider\Contract\StreamingCapableInterface

Contract for providers supporting streaming.

streamChatCompletion ( array $messages, array $options = []) : Generator

Stream chat completion.

supportsStreaming ( ) : bool

Check if streaming is supported.

interface ToolCapableInterface
Fully qualified name
\Netresearch\NrLlm\Provider\Contract\ToolCapableInterface

Contract for providers supporting tool/function calling.

chatCompletionWithTools ( array $messages, array $tools, array $options = []) : CompletionResponse

Chat with tool calling. Messages support multimodal content (string or array of content blocks).

supportsTools ( ) : bool

Check if tool calling is supported.

Exceptions 

interface NrLlmExceptionInterface
Fully qualified name
\Netresearch\NrLlm\Exception\NrLlmExceptionInterface

Marker interface implemented by every exception this extension throws on its public API surface — including the fromArray() normalisation errors of ChatMessage / ToolSpec / ToolCall. Catch this when any nr_llm failure should take the same error path (ADR-053):

try {
    $response = $this->llmManager->chatWithTools($messages, $tools);
} catch (NrLlmExceptionInterface $e) {
    throw new MyDomainException($e->getMessage(), 0, $e);
}
Copied!
class ProviderException
Fully qualified name
\Netresearch\NrLlm\Provider\Exception\ProviderException

Base exception for provider errors.

getProvider ( ) : string

Get the provider that threw the exception.

class ProviderConfigurationException
Fully qualified name
\Netresearch\NrLlm\Provider\Exception\ProviderConfigurationException

Thrown when a provider is incorrectly configured.

Extends \Netresearch\NrLlm\Provider\Exception\ProviderException

class ProviderConnectionException
Fully qualified name
\Netresearch\NrLlm\Provider\Exception\ProviderConnectionException

Thrown when a connection to the provider fails.

Extends \Netresearch\NrLlm\Provider\Exception\ProviderException

class ProviderResponseException
Fully qualified name
\Netresearch\NrLlm\Provider\Exception\ProviderResponseException

Thrown when the provider returns an unexpected or error response.

Extends \Netresearch\NrLlm\Provider\Exception\ProviderException

class UnsupportedFeatureException
Fully qualified name
\Netresearch\NrLlm\Provider\Exception\UnsupportedFeatureException

Thrown when a requested feature is not supported by the provider.

Extends \Netresearch\NrLlm\Provider\Exception\ProviderException

class InvalidArgumentException
Fully qualified name
\Netresearch\NrLlm\Exception\InvalidArgumentException

Thrown for invalid method arguments.

class ConfigurationNotFoundException
Fully qualified name
\Netresearch\NrLlm\Exception\ConfigurationNotFoundException

Thrown when a named configuration is not found.

class ConfigurationInactiveException
Fully qualified name
\Netresearch\NrLlm\Exception\ConfigurationInactiveException

Thrown when a named configuration exists but is deactivated (ADR-070).

Only ConfigurationResolver::getActiveByIdentifier() differentiates inactive from not-found; the user-aware LlmConfigurationServiceInterface::getConfiguration() signals the inactive case as ConfigurationNotFoundException (code 2690936773).

Events 

Architecture 

This section describes the architectural design of the TYPO3 LLM extension.

Consuming extensions call the nr-llm service layer, which resolves a Configuration to a Model to a Provider and reaches the LLM API through the middleware pipeline.

How a call travels: one injected interface, the three-tier resolution beneath it, and the middleware every provider call passes through.

Three-tier configuration architecture 

The extension uses a three-level hierarchical architecture separating concerns:

┌─────────────────────────────────────────────────────────────────────────┐
│ CONFIGURATION (Use-Case Specific)                                        │
│ "blog-summarizer", "product-description", "support-translator"          │
│                                                                          │
│ Fields: system_prompt, temperature, max_tokens, use_case_type           │
│ References: model_uid → Model                                            │
└──────────────────────────────────┬──────────────────────────────────────┘
                                   │ N:1
┌──────────────────────────────────▼──────────────────────────────────────┐
│ MODEL (Available Models)                                                 │
│ "gpt-5", "claude-sonnet-4-5", "llama-70b", "text-embedding-3-large"     │
│                                                                          │
│ Fields: model_id, context_length, capabilities, pricing                 │
│ References: provider_uid → Provider                                      │
└──────────────────────────────────┬──────────────────────────────────────┘
                                   │ N:1
┌──────────────────────────────────▼──────────────────────────────────────┐
│ PROVIDER (API Connections)                                               │
│ "openai-prod", "openai-dev", "local-ollama", "azure-openai-eu"          │
│                                                                          │
│ Fields: endpoint_url, api_key (encrypted), adapter_type, timeout        │
└─────────────────────────────────────────────────────────────────────────┘
Copied!

The same architecture expressed as PlantUML (for rendering with external tools):

Three-tier configuration architecture (PlantUML source)
@startuml
skinparam rectangle {
    BackgroundColor<<config>> #E8F5E9
    BackgroundColor<<model>>  #E3F2FD
    BackgroundColor<<provider>> #FFF3E0
}

rectangle "**CONFIGURATION**\n(Use-Case Specific)" <<config>> as C {
    note right
        blog-summarizer
        product-description
        support-translator
    end note
}

rectangle "**MODEL**\n(Available Models)" <<model>> as M {
    note right
        gpt-5, claude-sonnet-4-5
        llama-70b
        text-embedding-3-large
    end note
}

rectangle "**PROVIDER**\n(API Connections)" <<provider>> as P {
    note right
        openai-prod, openai-dev
        local-ollama
        azure-openai-eu
    end note
}

C -down-> M : "N:1\nmodel_uid"
M -down-> P : "N:1\nprovider_uid"
@enduml
Copied!

Benefits 

  • Multiple API keys per provider type: Separate production and development accounts.
  • Custom endpoints: Azure OpenAI, Ollama, vLLM, local models.
  • Reusable model definitions: Centralized capabilities and pricing.
  • Clear separation of concerns: Connection vs capability vs use-case.

Provider layer 

Represents a specific API connection with credentials.

Database table: tx_nrllm_provider

Field Type Description
identifier string Unique slug (e.g., openai-prod, ollama-local)
name string Display name (e.g., OpenAI Production)
adapter_type string Protocol: openai, anthropic, gemini, ollama, etc.
endpoint_url string Custom endpoint (empty = default)
api_key string nr-vault identifier (UUID) for the encrypted key
organization_id string Optional organization ID (OpenAI)
timeout int Request timeout in seconds
max_retries int Retry count on failure
options JSON Additional adapter-specific options

Key design points:

  • One provider = one API key = one billing relationship.
  • Same adapter type can have multiple providers (prod/dev accounts).
  • Adapter type determines the protocol/client class used.
  • API keys are stored as nr-vault identifiers (UUIDs); the raw key never touches nr-llm's tables.

Model layer 

Represents a specific model available through a provider.

Database table: tx_nrllm_model

Field Type Description
identifier string Unique slug (e.g., gpt-5.3-instant, claude-sonnet-4-6)
name string Display name (e.g., GPT-5.3 Instant (128K))
provider_uid int Foreign key to Provider
model_id string API model identifier (e.g., gpt-5.3-instant, claude-sonnet-4-6)
context_length int Token limit (e.g., 128000)
max_output_tokens int Output limit (e.g., 16384)
capabilities CSV Supported features: chat,vision,streaming,tools
cost_input int Cents per 1M input tokens
cost_output int Cents per 1M output tokens
is_default bool Default model for this provider

Key design points:

  • Models belong to exactly one provider.
  • Capabilities define what the model can do.
  • Pricing stored as integers (cents/1M tokens) to avoid float issues.
  • Same logical model can exist multiple times (different providers).

Configuration layer 

Represents a specific use case with model and prompt settings.

Database table: tx_nrllm_configuration

Field Type Description
identifier string Unique slug (e.g., blog-summarizer)
name string Display name (e.g., Blog Post Summarizer)
model_uid int Foreign key to Model
system_prompt text System message for the model
temperature float Creativity: 0.0 - 2.0
max_tokens int Response length limit
top_p float Nucleus sampling
presence_penalty float Topic diversity
frequency_penalty float Word repetition penalty
use_case_type string chat, completion, embedding, translation

Key design points:

  • Configurations reference models, not providers directly.
  • All LLM parameters are tunable per use case.
  • Same model can be used by multiple configurations.

Service layer 

The extension follows a layered service architecture:

┌─────────────────────────────────────────┐
│         Your Application Code           │
└────────────────┬────────────────────────┘
                 │
┌────────────────▼────────────────────────┐
│         Feature Services                │
│  (Completion, Embedding, Vision, etc.)  │
└────────────────┬────────────────────────┘
                 │
┌────────────────▼────────────────────────┐
│         LlmServiceManager               │
│    (Provider selection & routing)       │
└────────────────┬────────────────────────┘
                 │
┌────────────────▼────────────────────────┐
│       ProviderAdapterRegistry           │
│    (Maps adapters to database providers)│
└────────────────┬────────────────────────┘
                 │
┌────────────────▼────────────────────────┐
│       Provider Adapters                 │
│  (OpenAI, Claude, Gemini, Ollama, etc.) │
└─────────────────────────────────────────┘
Copied!

Feature services 

High-level services for common AI tasks:

  • CompletionService: Text generation with format control (JSON, Markdown).
  • EmbeddingService: Text-to-vector conversion with caching.
  • VisionService: Image analysis for alt-text, titles, descriptions.
  • TranslationService: Language translation with glossaries.

Provider adapters 

The extension includes adapters for multiple LLM providers:

  • OpenAI (OpenAiProvider): GPT-5.x series, o-series reasoning models.
  • Anthropic (ClaudeProvider): Claude Opus 4.5, Claude Sonnet 4.5, Claude Haiku 4.5.
  • Google (GeminiProvider): Gemini 3 Pro, Gemini 3 Flash, Gemini 2.5 series.
  • Ollama (OllamaProvider): Local model deployment.
  • OpenRouter (OpenRouterProvider): Multi-model routing.
  • Mistral (MistralProvider): Mistral models.
  • Groq (GroqProvider): Fast inference.

Security 

API key encryption 

API keys are never stored as plaintext in nr-llm's own tables. Each provider record holds a vault identifier (UUID) issued by the nr-vault extension, which performs envelope encryption with audited access.

  • The database stores only the vault UUID, never a raw key.
  • Retrieval and injection into outbound requests go through nr-vault's secure, SSRF-guarded HTTP client.
  • Key rotation is handled by nr-vault.

For the historical sodium-based design that this replaced, see ADR-012: API key encryption at application level.

Supported adapter types 

Adapter Type PHP Class Default Endpoint
openai OpenAiProvider https://api.openai.com/v1
anthropic ClaudeProvider https://api.anthropic.com/v1
gemini GeminiProvider https://generativelanguage.googleapis.com/v1beta
ollama OllamaProvider http://localhost:11434
openrouter OpenRouterProvider https://openrouter.ai/api/v1
mistral MistralProvider https://api.mistral.ai/v1
groq GroqProvider https://api.groq.com/openai/v1
azure_openai OpenAiProvider (custom Azure endpoint)
custom OpenAiProvider (custom endpoint)

Testing guide 

Comprehensive testing guide for the TYPO3 LLM extension.

A push or pull request fans out into static analysis, the test matrix, security checks and documentation rendering; a subset of the resulting contexts is required by the branch ruleset, and the merge queue re-runs them before the merge lands.

The jobs run in parallel, not in sequence. Which of them can actually block a merge is a property of the branch ruleset, not of the workflow.

Overview 

The extension includes a comprehensive test suite:

Test Type Count Purpose
Unit tests 2735 Individual class and method testing.
Integration tests 39 Service interaction and provider testing.
E2E tests 127 Full workflow testing with real APIs.
Functional tests 285 TYPO3 framework integration.
Fuzzy tests 79 Fuzzy/property-based testing.

Unit testing 

Running tests 

Prerequisites 

Install development dependencies
# Install dependencies (dev deps included by default)
composer install
Copied!

Unit tests 

Run unit tests
# Recommended: Use runTests.sh (Docker-based, consistent environment)
Build/Scripts/runTests.sh -s unit

# With specific PHP version
Build/Scripts/runTests.sh -s unit -p 8.3

# Alternative: Via Composer script
composer ci:test:php:unit
Copied!

Integration tests 

Run integration tests
# Run integration tests (requires API keys)
OPENAI_API_KEY=your-api-key-here \
    Build/Scripts/runTests.sh -s functional
Copied!

All tests 

Run complete test suite
# Run all test suites via runTests.sh
Build/Scripts/runTests.sh -s unit
Build/Scripts/runTests.sh -s functional

# Run code quality checks
Build/Scripts/runTests.sh -s cgl
Build/Scripts/runTests.sh -s phpstan
Copied!

Test structure 

Test directory structure
Tests/
├── Unit/
│   ├── Domain/
│   │   └── Model/
│   │       ├── CompletionResponseTest.php
│   │       ├── EmbeddingResponseTest.php
│   │       └── UsageStatisticsTest.php
│   ├── Provider/
│   │   ├── OpenAiProviderTest.php
│   │   ├── ClaudeProviderTest.php
│   │   ├── GeminiProviderTest.php
│   │   └── AbstractProviderTest.php
│   └── Service/
│       ├── LlmServiceManagerTest.php
│       └── Feature/
│           ├── CompletionServiceTest.php
│           ├── EmbeddingServiceTest.php
│           ├── VisionServiceTest.php
│           └── TranslationServiceTest.php
├── Integration/
│   ├── Provider/
│   │   └── ProviderIntegrationTest.php
│   └── Service/
│       └── ServiceIntegrationTest.php
├── Functional/
│   ├── Controller/
│   │   └── BackendControllerTest.php
│   └── Repository/
│       └── ProviderRepositoryTest.php
└── E2E/
    └── WorkflowTest.php
Copied!

Writing tests 

Unit test example 

Example: Unit test
namespace Netresearch\NrLlm\Tests\Unit\Service;

use Netresearch\NrLlm\Domain\Model\CompletionResponse;
use Netresearch\NrLlm\Domain\Model\UsageStatistics;
use Netresearch\NrLlm\Provider\Contract\ProviderInterface;
use Netresearch\NrLlm\Service\LlmServiceManager;
use PHPUnit\Framework\TestCase;

class LlmServiceManagerTest extends TestCase
{
    private LlmServiceManager $subject;

    protected function setUp(): void
    {
        parent::setUp();

        $mockProvider = $this->createMock(ProviderInterface::class);
        $mockProvider->method('getIdentifier')->willReturn('test');
        $mockProvider->method('isConfigured')->willReturn(true);

        $this->subject = new LlmServiceManager(
            providers: [$mockProvider]
        );
    }

    public function testChatReturnsCompletionResponse(): void
    {
        $provider = $this->createMock(ProviderInterface::class);
        $provider->method('chatCompletion')->willReturn(
            new CompletionResponse(
                content: 'Hello!', model: 'test-model',
                usage: new UsageStatistics(10, 5, 15),
                finishReason: 'stop', provider: 'test'
            )
        );
        // ... test implementation
    }

    /**
     * @dataProvider invalidMessagesProvider
     */
    public function testChatThrowsOnInvalidMessages(array $messages): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->subject->chat($messages);
    }

    public static function invalidMessagesProvider(): array
    {
        return [
            'empty messages' => [[]],
            'missing role' => [[['content' => 'test']]],
            'missing content' => [[['role' => 'user']]],
            'invalid role' => [[['role' => 'invalid', 'content' => 'test']]],
        ];
    }
}
Copied!

Mocking providers 

Using mock provider 

Example: Mock provider
use Netresearch\NrLlm\Domain\Model\CompletionResponse;
use Netresearch\NrLlm\Domain\Model\UsageStatistics;
use Netresearch\NrLlm\Provider\Contract\ProviderInterface;

$mockProvider = $this->createMock(ProviderInterface::class);
$mockProvider
    ->method('chatCompletion')
    ->willReturn(new CompletionResponse(
        content: 'Mocked response',
        model: 'mock-model',
        usage: new UsageStatistics(100, 50, 150),
        finishReason: 'stop',
        provider: 'mock'
    ));
$mockProvider->method('isConfigured')->willReturn(true);
Copied!

Using HTTP mock 

Example: HTTP mock
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;

$mock = new MockHandler([
    new Response(200, [], json_encode([
        'choices' => [
            [
                'message' => ['content' => 'Test response'],
                'finish_reason' => 'stop',
            ],
        ],
        'model' => 'gpt-5',
        'usage' => [
            'prompt_tokens' => 10,
            'completion_tokens' => 5,
            'total_tokens' => 15,
        ],
    ])),
]);

$handlerStack = HandlerStack::create($mock);
$client = new Client(['handler' => $handlerStack]);

$provider = new OpenAiProvider(
    httpClient: $client,
    // ...
);
Copied!

Functional testing 

Running functional tests 

Run functional tests
# Run TYPO3 functional tests
Build/Scripts/runTests.sh -s functional

# Alternative: Via Composer script
composer ci:test:php:functional
Copied!

Functional test example 

Example: Functional test
<?php

namespace Netresearch\NrLlm\Tests\Functional\Repository;

use Netresearch\NrLlm\Domain\Model\Provider;
use Netresearch\NrLlm\Domain\Repository\ProviderRepository;
use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;

class ProviderRepositoryTest extends FunctionalTestCase
{
    protected array $testExtensionsToLoad = [
        'netresearch/nr-llm',
    ];

    private ProviderRepository $repository;

    protected function setUp(): void
    {
        parent::setUp();
        $this->repository = $this->get(ProviderRepository::class);
    }

    public function testFindOneByIdentifierReturnsProvider(): void
    {
        $this->importCSVDataSet(__DIR__ . '/Fixtures/providers.csv');

        $provider = $this->repository->findOneByIdentifier('openai-test');

        $this->assertInstanceOf(Provider::class, $provider);
        $this->assertEquals('OpenAI Test', $provider->getName());
    }
}
Copied!

Test fixtures 

CSV fixtures 

Tests/Functional/Fixtures/providers.csv
"tx_nrllm_provider"
"uid","pid","identifier","name","adapter_type","is_active"
1,0,"openai-test","OpenAI Test","openai",1
Copied!

JSON response fixtures 

Tests/Fixtures/openai_chat_response.json
{
  "id": "chatcmpl-123",
  "object": "chat.completion",
  "created": 1677652288,
  "model": "gpt-5",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Test response"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 10,
    "completion_tokens": 5,
    "total_tokens": 15
  }
}
Copied!

Mutation testing 

The extension uses Infection for mutation testing to ensure test quality.

Running mutation tests 

Run mutation tests
# Run mutation tests via runTests.sh
Build/Scripts/runTests.sh -s mutation

# Alternative: Via Composer script
composer ci:test:php:mutation
Copied!

Interpreting results 

  • MSI (Mutation Score Indicator): Percentage of mutations killed.
  • Target: >60% MSI indicates good test quality.
  • Current: 58% MSI (459 tests).
Mutation testing results
Mutation Score Indicator (MSI): 58%
Mutation Code Coverage: 85%
Covered Code MSI: 68%
Copied!

Best practices 

  1. Isolate tests: Each test should be independent.
  2. Mock external APIs: Never call real APIs in unit tests.
  3. Use data providers: For testing multiple scenarios.
  4. Test edge cases: Empty inputs, null values, boundaries.
  5. Descriptive names: Test method names should describe behavior.
  6. Arrange-Act-Assert: Follow AAA pattern.
  7. Fast tests: Unit tests should complete in milliseconds.
  8. Coverage goals: Aim for >80% line coverage.

E2E testing 

Overview 

E2E tests verify complete workflows from service entry point through to response handling. They use mocked HTTP clients to simulate external API interactions without requiring real API keys.

Tests are located in Tests/E2E/ and include:

  • Workflow tests — full chat completion, embedding, and TCA field completion flows
  • Backend module tests — provider, model, configuration, and task management
  • Playwright tests — browser-based UI tests for the backend module

Running E2E tests 

Run E2E tests
# PHP-based E2E tests (mocked HTTP, in unit suite)
Build/Scripts/runTests.sh -s unit -- Tests/E2E/

# Playwright browser E2E tests
Build/Scripts/runTests.sh -s e2e
Copied!

E2E test example 

Example: E2E workflow test
namespace Netresearch\NrLlm\Tests\E2E;

use Netresearch\NrLlm\Domain\Model\CompletionResponse;
use Netresearch\NrLlm\Provider\OpenAiProvider;
use Netresearch\NrLlm\Provider\ProviderAdapterRegistry;
use Netresearch\NrLlm\Service\Feature\CompletionService;
use Netresearch\NrLlm\Service\LlmServiceManager;
use Psr\Log\NullLogger;
use TYPO3\CMS\Core\Configuration\ExtensionConfiguration;

class ChatWorkflowTest extends AbstractE2ETestCase
{
    public function testCompleteWorkflow(): void
    {
        $responseData = $this->createOpenAiChatResponse(
            content: 'Hello!',
            model: 'gpt-4o',
        );
        $httpClient = $this->createMockHttpClient([
            $this->createJsonResponse($responseData),
        ]);

        $provider = new OpenAiProvider(
            $this->requestFactory,
            $this->streamFactory,
            $this->logger,
            $this->createVaultServiceMock(),
            $this->createSecureHttpClientFactoryMock(),
        );

        $extConfig = self::createStub(
            ExtensionConfiguration::class
        );
        $extConfig->method('get')->willReturn([
            'providers' => ['openai' => ['apiKeyIdentifier' => 'sk-test']],
        ]);

        $registry = self::createStub(
            ProviderAdapterRegistry::class
        );
        $manager = new LlmServiceManager(
            $extConfig,
            new NullLogger(),
            $registry,
        );
        $manager->registerProvider($provider);
        $provider->setHttpClient($httpClient);

        $service = new CompletionService($manager);
        $result = $service->complete('Hello!');

        self::assertInstanceOf(
            CompletionResponse::class,
            $result,
        );
        self::assertSame(
            'Hello!',
            $result->content,
        );
    }
}
Copied!

CI configuration 

GitHub Actions 

.github/workflows/tests.yml
name: Tests

on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        php: ['8.2', '8.3', '8.4', '8.5']
        typo3: ['13.4', '14.3']

    steps:
      - uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: ${{ matrix.php }}
          coverage: xdebug

      - name: Install dependencies
        run: composer install --prefer-dist

      - name: Run tests
        run: composer test

      - name: Upload coverage
        uses: codecov/codecov-action@v3
        with:
          files: coverage/clover.xml
Copied!

GitLab CI/CD 

.gitlab-ci.yml
test:
  image: php:8.2
  script:
    - composer install
    - composer test
  coverage: '/^\s*Lines:\s*\d+.\d+\%/'
Copied!

Architecture Decision Records 

This section documents significant architectural decisions made during the development of the TYPO3 LLM Extension.

Record lifecycle 

An ADR is a record of a decision at a point in time. It is expected to become historically wrong; what it must never do is look current when it is not. The :Status: field is how a reader tells the difference.

Accepted
Current. The decision and the facts it reasons from still hold.
Accepted with an :Amended: field
Still current as a whole, but a later ADR overturned, widened or expired part of it. The status line names which part in parentheses; the :Amended: field names the date and the amending record.
Superseded with a :Superseded: field
No longer current. The field names the date and the replacing record.
Deprecated
What it decided is being removed, with no successor decision.

The link is written from both ends. The newer record declares :Amends: or :Supersedes:; the older one declares :Amended: or :Superseded: with the date. Tests/Unit/AdrLifecycleTest.php fails when only one end of an ADR-to-ADR link is written.

Not every successor is an ADR. ADR-012 was superseded by the nr-vault integration, which no record decided, so its field names that in prose and has no counterpart. A field whose body references no ADR is outside the pairing check by construction — which is also why prose there must stay specific enough to follow.

Two rules follow from the pairing:

An amended record keeps its reasoning. ADR-122 declined to build a side-effecting tool contract because no tool wrote. That premise expired with ADR-135, but the reasoning — do not design a contract ahead of its first consumer — is why the writer shipped without a framework. The record stays; the status says the premise is gone.

Amending is the amender's job. An ADR that overturns part of an earlier one edits that earlier record's :Status: and :Amended: in the same change. An accepted ADR with an expired premise and a clean Accepted status is a defect, not history.

Symbol legend 

Each consequence in the ADRs is marked with severity symbols to indicate impact weight:

Symbol Meaning Weight
●● Strong Positive +2 to +3
Medium Positive +1 to +2
Light Positive +0.5 to +1
Medium Negative -1 to -2
✕✕ Strong Negative -2 to -3
Light Negative -0.5 to -1

Net Score indicates the overall impact of the decision (sum of weights).

Decision records 

Foundation 

ADR-001: Provider abstraction layer 

Unified interface for OpenAI, Claude, Gemini, Ollama, and more.

ADR-002: Feature services architecture 

Translation, vision, embeddings, completion as injectable services.

ADR-003: Typed response objects 

Immutable value objects for all LLM responses.

ADR-007: Multi-provider strategy 

Fallback chains and provider selection logic.

ADR-013: Three-level configuration 

Provider -> Model -> Configuration hierarchy.

TYPO3 integration 

ADR-004: PSR-14 event system 

Extension points via TYPO3 events.

ADR-005: Caching framework 

Instance-default backend, nrllm cache group.

ADR-012: API key encryption 

Superseded — now via nr-vault envelope encryption.

API design 

ADR-006: Option objects vs arrays 

Typed option objects for API calls.

ADR-008: Error handling strategy 

Exception hierarchy and retry logic.

ADR-009: Streaming implementation 

Chunked transfer for real-time output.

ADR-010: Tool/function calling 

Provider-agnostic tool call abstraction.

ADR-011: Object-only options API 

Removed array support, typed objects only.

Modern architecture (v0.4+) 

ADR-014: AI-powered wizard system 

Natural language -> structured configuration generation with fallback defaults.

ADR-015: Type-safe domain models 

PHP 8.1+ enums, DTOs, and value objects.

ADR-016: Thinking block extraction 

Reasoning blocks from Claude, DeepSeek, Qwen.

ADR-017: SafeCastTrait 

PHPStan level 10 compliance for mixed input.

ADR-018: Model discovery 

Multi-provider model listing with fallback catalogs.

ADR-019: Internationalization 

XLIFF + locale-aware features with {lang} placeholders.

ADR-020: Output format rendering 

Client-side plain/markdown/HTML toggle.

ADR-037: Backend AJAX admin guard 

Shared trait requires a backend admin on every backend AJAX endpoint (403 otherwise).

Skills 

ADR-035: Skill ingest 

GitHub-hosted SKILL.md sources: host allowlist, SHA-pin + checksum, disabled-by-default review.

ADR-036: Skill injection 

Attach skills to tasks/configurations; compose into the user prompt (text-gen only), budgeted and checksum-verified.

Tools 

ADR-038: Tool runtime 

DI-tagged tool registry + bounded agent loop on the config's vault key/model/pricing; allow-list gated, admin-only.

ADR-039: Global tool availability 

Site-wide per-tool enable/disable override (tx_nrllm_tool_state, no TCA) intersected with every run's allow-list — a hard admin kill switch.

ADR-001: Provider Abstraction Layer 

Status

Accepted

Date

2024-01

Authors

Netresearch DTT GmbH

Context 

We needed to support multiple LLM providers (OpenAI, Anthropic Claude, Google Gemini) while maintaining a consistent API for consumers. Each provider has different:

  • API endpoints and authentication methods
  • Request/response formats
  • Model naming conventions
  • Capability sets (vision, embeddings, streaming, tools)

Decision 

Implement a provider abstraction layer with:

  1. ProviderInterface as the core contract.
  2. Capability interfaces for optional features (embeddings are a core ProviderInterface method, not an opt-in capability):

    • VisionCapableInterface.
    • StreamingCapableInterface.
    • ToolCapableInterface.
    • DocumentCapableInterface.
  3. AbstractProvider base class with shared functionality.
  4. LlmServiceManager as the unified entry point.

Consequences 

Positive:

  • ●● Consumers use single API regardless of provider.
  • ●● Easy to add new providers.
  • ● Capability checking via interface detection.
  • ●● Provider switching requires no code changes.

Negative:

  • ✕ Lowest common denominator for shared features.
  • ◑ Provider-specific features require direct provider access.
  • ◑ Additional abstraction layer complexity.

Net Score: +5.5 (Strong positive impact - abstraction enables flexibility and maintainability)

Alternatives considered 

  1. Single monolithic class: Rejected due to maintenance complexity.
  2. Strategy pattern only: Insufficient for capability detection.
  3. Factory pattern: Used in combination with interfaces.

ADR-002: Feature Services Architecture 

Status

Accepted

Date

2024-02

Authors

Netresearch DTT GmbH

Context 

Common LLM tasks (translation, image analysis, embeddings) require:

  • Specialized prompts and configurations
  • Pre/post-processing logic
  • Caching strategies
  • Quality control measures

Decision 

Create dedicated Feature Services for high-level operations:

  • CompletionService: Text generation with format control.
  • EmbeddingService: Vector operations with caching.
  • VisionService: Image analysis with specialized prompts.
  • TranslationService: Language translation with quality scoring.

Each service:

  • Uses LlmServiceManager internally.
  • Provides domain-specific methods.
  • Handles caching and optimization.
  • Returns typed response objects.

Consequences 

Positive:

  • ●● Clear separation of concerns.
  • ● Reusable, tested implementations.
  • ●● Consistent behavior across use cases.
  • ● Built-in best practices (caching, prompts).

Negative:

  • ◑ Additional classes to maintain.
  • ◑ Potential duplication with manager methods.
  • ◑ Learning curve for service selection.

Net Score: +6.5 (Strong positive impact - services provide high-level abstractions with best practices)

ADR-003: Typed Response Objects 

Status

Accepted

Date

2024-01

Authors

Netresearch DTT GmbH

Context 

Provider APIs return different response structures. We needed to:

  • Provide consistent response format to consumers.
  • Enable IDE autocompletion and type checking.
  • Include relevant metadata (usage, model, finish reason).

Decision 

Use immutable value objects for responses:

Example: CompletionResponse value object
final class CompletionResponse
{
    public function __construct(
        public readonly string $content,
        public readonly string $model,
        public readonly UsageStatistics $usage,
        public readonly string $finishReason,
        public readonly string $provider,
        public readonly ?array $toolCalls = null,
    ) {}
}
Copied!

Key characteristics:

  • final classes prevent inheritance issues.
  • readonly properties ensure immutability.
  • Constructor promotion for concise definition.
  • Nullable for optional data.

Consequences 

Positive:

  • ●● Strong typing with IDE support.
  • ● Immutable objects are thread-safe.
  • ●● Clear API contract.
  • ● Easy testing and mocking.

Negative:

  • ◑ Cannot extend responses.
  • ✕ Breaking changes require new properties.
  • ◑ Slight memory overhead vs arrays.

Net Score: +5.5 (Strong positive impact - type safety and immutability outweigh flexibility limitations)

ADR-004: PSR-14 Event System 

Status

Superseded

Date

2024-02

Superseded

2026 by ADR-026

Authors

Netresearch DTT GmbH

Context 

Consumers need extension points for:

  • Logging and monitoring.
  • Request modification.
  • Response processing.
  • Cost tracking and rate limiting.

Decision 

Use TYPO3's PSR-14 event system with events:

  • BeforeRequestEvent: Modify requests before sending.
  • AfterResponseEvent: Process responses after receiving.

Events are dispatched by LlmServiceManager and provide:

  • Full context (messages, options, provider).
  • Mutable options (before request).
  • Response data (after response).
  • Timing information.

Consequences 

Positive:

  • ●● Follows TYPO3 conventions.
  • ●● Decoupled extension mechanism.
  • ● Multiple listeners without modification.
  • ● Testable event handlers.

Negative:

  • ◑ Event overhead on every request.
  • ◑ Listener ordering considerations.
  • ◑ Debugging event flow complexity.

Net Score: +6.5 (Strong positive impact - standard TYPO3 integration with decoupled extensibility)

ADR-005: TYPO3 Caching Framework Integration 

Status

Accepted

Date

2024-03

Authors

Netresearch DTT GmbH

Context 

LLM API calls are:

  • Expensive (cost per token).
  • Relatively slow (network latency).
  • Often deterministic (embeddings, some completions).

Decision 

Integrate with TYPO3's caching framework:

  • Cache identifier: nrllm_responses.
  • No backend specified — TYPO3 uses the instance's default cache backend (respects Redis/Valkey/Memcached).
  • Cache keys based on: provider + model + input hash.
  • TTL: 3600s default (configurable).
  • Cache group: nrllm (flush via cache:flush --group=nrllm).

Caching strategy:

  • Always cache: Embeddings (deterministic).
  • Optional cache: Completions with temperature=0.
  • Never cache: Streaming, tool calls, high temperature.

Consequences 

Positive:

  • ●● Reduced API costs.
  • ●● Faster responses for cached content.
  • ● Follows TYPO3 patterns.
  • ◐ Configurable per deployment.

Negative:

  • ✕ Cache invalidation complexity.
  • ◑ Storage requirements.
  • ✕ Stale responses if TTL too long.

Net Score: +4.5 (Positive impact - significant cost/performance gains with manageable cache complexity)

ADR-006: Option Objects vs Arrays 

Status

Superseded

Date

2024-12

Superseded

2024-12 by ADR-011

Authors

Netresearch DTT GmbH

Context 

Method signatures like chat(array $messages, array $options) lack:

  • Type safety and validation.
  • IDE autocompletion.
  • Documentation of available options.
  • Factory methods for common configurations.

Decision 

Introduce Option Objects (initially with array backwards compatibility):

Example: Using ChatOptions
// Option objects only
$options = ChatOptions::creative()
    ->withMaxTokens(2000)
    ->withSystemPrompt('Be creative');

$response = $llmManager->chat($messages, $options);
Copied!

Implementation:

  • Pure object signatures: ?ChatOptions.
  • Factory presets: factual(), creative(), json().
  • Fluent builder pattern.
  • Validation in constructors.

Consequences 

Positive:

  • ● IDE autocompletion for options.
  • ● Built-in validation.
  • ● Convenient factory presets.
  • ●● Type safety enforced.
  • ● Single consistent API.

Negative:

  • ◑ Migration required for existing code.
  • ◑ No array syntax available.

Net Score: +5.5 (Strong positive impact - developer experience improvements with backwards compatibility)

ADR-007: Multi-Provider Strategy 

Status

Accepted

Date

2024-01

Authors

Netresearch DTT GmbH

Context 

Supporting multiple providers requires:

  • Dynamic provider registration.
  • Priority-based selection.
  • Configuration per provider.
  • Fallback mechanisms.

Decision 

Use tagged service collection with priority:

Configuration/Services.yaml
# Services.yaml
Netresearch\NrLlm\Provider\OpenAiProvider:
  tags:
    - name: nr_llm.provider
      priority: 100

Netresearch\NrLlm\Provider\ClaudeProvider:
  tags:
    - name: nr_llm.provider
      priority: 90
Copied!

Provider selection:

  1. Explicit provider in the per-call options.
  2. Otherwise the active DB-backed default configuration's provider.
  3. Otherwise getProvider(null) throws a ProviderException.

There is deliberately no "first provider by priority" fallback: the implicit default-provider fallback was removed in ADR-034, so provider selection is always explicit (per-call option or the active configuration).

Consequences 

Positive:

  • ● Easy provider registration.
  • ● Clear priority system.
  • ●● Supports custom providers.
  • ● Automatic fallback.

Negative:

  • ◑ Priority conflicts possible.
  • ◑ All providers instantiated.
  • ◑ Configuration complexity.

Net Score: +5.5 (Strong positive impact - flexible multi-provider support with minor overhead)

ADR-008: Error Handling Strategy 

Status

Accepted

Date

2024-02

Authors

Netresearch DTT GmbH

Context 

LLM operations can fail due to:

  • Authentication issues.
  • Rate limiting.
  • Network errors.
  • Content filtering.
  • Invalid inputs.

Decision 

Implement hierarchical exception system:

Exception hierarchy (Classes/Provider/Exception/ + Classes/Exception/)
\RuntimeException
├── Netresearch\NrLlm\Provider\Exception\ProviderException (base for provider errors)
│   ├── ProviderConnectionException (transport / network failure)
│   ├── ProviderResponseException (non-2xx / malformed API response)
│   ├── ProviderConfigurationException (missing/invalid provider setup)
│   ├── UnsupportedFeatureException (capability not implemented)
│   └── FallbackChainExhaustedException (all providers in the chain failed)
└── Netresearch\NrLlm\Exception\ConfigurationNotFoundException (missing configuration record)
\InvalidArgumentException
└── Netresearch\NrLlm\Exception\InvalidArgumentException (bad inputs)
Copied!

Key features:

  • All provider errors extend ProviderException (itself a RuntimeException).
  • FallbackChainExhaustedException is raised by FallbackMiddleware when every provider in the chain fails (ADR-021, ADR-026).
  • ProviderResponseException carries the offending HTTP status and a sanitised message (secrets stripped by ErrorMessageSanitizerTrait).
  • Exceptions include provider context.

Consequences 

Positive:

  • ●● Granular error handling.
  • ● Provider-specific recovery strategies.
  • ● Clear exception hierarchy.
  • ● Actionable error information.

Negative:

  • ◑ Many exception classes.
  • ◑ Exception handling complexity.
  • ✕ Breaking changes in new versions.

Net Score: +5.0 (Positive impact - robust error handling enables graceful recovery strategies)

ADR-009: Streaming Implementation 

Status

Accepted

Date

2024-03

Authors

Netresearch DTT GmbH

Context 

Streaming responses provide:

  • Better UX for long responses.
  • Lower time-to-first-token.
  • Real-time feedback.

Decision 

Use PHP Generators for streaming:

Example: Streaming chat responses
public function streamChat(array $messages, array $options = []): Generator
{
    $response = $this->sendStreamingRequest($messages, $options);

    foreach ($this->parseSSE($response) as $chunk) {
        yield $chunk;
    }
}

// Usage
foreach ($llmManager->streamChat($messages) as $chunk) {
    echo $chunk;
    flush();
}
Copied!

Implementation details:

  • Server-Sent Events (SSE) parsing.
  • Chunked transfer encoding.
  • Memory-efficient iteration.
  • Provider-specific adaptations.

Consequences 

Positive:

  • ●● Memory efficient.
  • ● Natural iteration syntax.
  • ●● Real-time output.
  • ◐ Works with output buffering.

Negative:

  • ✕ No response object until complete.
  • ◑ Error handling complexity.
  • ◑ Connection management.
  • ✕ No caching possible.

Net Score: +3.5 (Positive impact - streaming UX benefits outweigh implementation complexity)

Update (2026-07): streaming is in the request lifecycle 

The generator mechanism decided here is unchanged, but streamed calls no longer sidestep the cross-cutting concerns the non-streaming path enforces. Originally a streamed call ran no budget pre-flight and produced neither a usage nor a telemetry row — a live budget hole.

Streamed calls now run through a dedicated streaming lifecycle (ADR-062: Streaming Request Lifecycle): an eager budget pre-flight before the first chunk, pre-first-chunk provider fallback, time-to-first-token measurement, and a finally that records usage (tx_nrllm_service_usage) and telemetry (tx_nrllm_telemetry) on every exit — completion, exception, or an abandoned generator. The lifecycle wraps the generator; the public Generator<int, string, mixed, void> contract is unchanged.

Two of the "Negative" consequences above are now bounded rather than open: "error handling complexity" and "connection management" are handled once, in the lifecycle, instead of at each call site. "No response object until complete" and "no caching possible" remain intrinsic to streaming. Token usage on this path is estimated (providers expose no usage frame mid-stream); see ADR-062: Streaming Request Lifecycle for the rationale and the follow-up toward exact figures.

ADR-010: Tool/Function Calling Design 

Status

Accepted

Date

2024-04

Authors

Netresearch DTT GmbH

Context 

Modern LLMs support tool/function calling for:

  • External data retrieval.
  • Action execution.
  • Structured output generation.

Decision 

Support OpenAI-compatible tool format:

Example: Tool definition
$tools = [
    [
        'type' => 'function',
        'function' => [
            'name' => 'get_weather',
            'description' => 'Get weather for location',
            'parameters' => [
                'type' => 'object',
                'properties' => [
                    'location' => ['type' => 'string'],
                ],
                'required' => ['location'],
            ],
        ],
    ],
];
Copied!

Tool calls returned in CompletionResponse::$toolCalls:

  • A typed list<ToolCall> (nullable) of ToolCall value objects — each with the tool id, name and its arguments as an already JSON-decoded associative array (not an encoded string). A full tool-execution runtime was added later in ADR-038, and the PHP tool return type evolved from a plain string to a typed ToolResult (provider-facing content plus run-only artifacts) in ADR-108.

Consequences 

Positive:

  • ●● Industry-standard format.
  • ●● Cross-provider compatibility.
  • ● Flexible tool definitions.
  • ● Type-safe parameters.

Negative:

  • ◑ Complex nested structure.
  • ◑ Provider translation needed.
  • ✕ No automatic execution.
  • ◑ Testing complexity.

Net Score: +5.0 (Positive impact - OpenAI-compatible format ensures broad compatibility)

ADR-011: Object-Only Options API 

Status

Accepted

Date

2024-12

Supersedes

ADR-006

Authors

Netresearch DTT GmbH

Context 

ADR-006 introduced Option Objects with array backwards compatibility (union types ChatOptions|array). This dual-path approach created:

  • Unnecessary complexity in the codebase.
  • OptionsResolverTrait with 6 resolution methods.
  • fromArray() methods in all Option classes.
  • Cognitive load deciding which syntax to use.
  • Inconsistent usage patterns across the codebase.

Given that:

  • No external users exist yet (pre-release).
  • No breaking change impact on third parties.
  • Clean break is possible without migration burden.

Decision 

Remove array support entirely. Use typed Option objects only:

Example: Object-only options API
// All methods now use nullable typed parameters
public function chat(array $messages, ?ChatOptions $options = null): CompletionResponse;
public function embed(string|array $input, ?EmbeddingOptions $options = null): EmbeddingResponse;
public function vision(array $content, ?VisionOptions $options = null): VisionResponse;

// Usage with factory presets
$response = $llmManager->chat($messages, ChatOptions::creative());

// Usage with custom options
$response = $llmManager->chat($messages, new ChatOptions(
    temperature: 0.7,
    maxTokens: 2000
));

// Usage with defaults (null)
$response = $llmManager->chat($messages);
Copied!

Implementation:

  • Signatures: ?ChatOptions instead of ChatOptions|array.
  • Defaults: null creates default Options in method body.
  • Removed: OptionsResolverTrait, all fromArray() methods.
  • Preserved: Factory presets, fluent builders, validation.

Consequences 

Positive:

  • ●● Type safety enforced at compile time.
  • ●● Single consistent API pattern.
  • ● Reduced codebase complexity ( 250 lines removed).
  • ● No trait usage or resolution overhead.
  • ● Better IDE support without union types.
  • ◐ Cleaner method signatures.

Negative:

  • ◑ No array syntax for quick prototyping.
  • ◑ Slightly more verbose for simple cases.

Net Score: +6.0 (Strong positive - type safety and consistency outweigh minor verbosity increase)

Files changed 

Deleted:

  • Classes/Service/Option/OptionsResolverTrait.php

Modified:

  • Classes/Service/Option/AbstractOptions.php - Removed fromArray() abstract.
  • Classes/Service/Option/ChatOptions.php - Removed fromArray().
  • Classes/Service/Option/EmbeddingOptions.php - Removed fromArray().
  • Classes/Service/Option/VisionOptions.php - Removed fromArray().
  • Classes/Service/Option/ToolOptions.php - Removed fromArray().
  • Classes/Service/Option/TranslationOptions.php - Removed fromArray().
  • Classes/Service/LlmServiceManager.php - Object-only signatures.
  • Classes/Service/LlmServiceManagerInterface.php - Object-only signatures.
  • Classes/Service/Feature/*Service.php - All feature services updated.
  • Classes/Specialized/Translation/LlmTranslator.php - Uses ChatOptions objects.

ADR-012: API key encryption at application level 

Status

Superseded

Date

2024-12-27

Superseded

2025-01 by nr-vault integration

Authors

Netresearch DTT GmbH

Context 

The nr_llm extension stores API keys for various LLM providers (OpenAI, Anthropic, etc.) in the database. These credentials are sensitive and require protection.

Problem statement 

TYPO3's TCA type=password field has two modes:

  1. Hashed mode (default): Uses bcrypt/argon2 - irreversible, suitable for user passwords
  2. Unhashed mode (hashed => false): Stores plaintext - required for API keys that must be retrieved

API keys must be retrievable to authenticate with external services, so hashing is not an option. However, storing them in plaintext exposes them to:

  • Database dumps/backups
  • SQL injection attacks
  • Unauthorized database access
  • Accidental exposure in logs

Requirements 

  1. API keys must be retrievable (not hashed).
  2. Keys must be encrypted at rest in the database.
  3. Encryption must be transparent to the application.
  4. Solution must work without external dependencies (self-contained).
  5. Must support key rotation.
  6. Backwards compatible with existing plaintext values.

Decision 

Implement application-level encryption using sodium_crypto_secretbox (XSalsa20-Poly1305) with key derivation from TYPO3's encryptionKey.

Architecture 

┌─────────────────────────────────────────────────────────────────┐
│                        Backend Form                              │
│                    (user enters API key)                         │
└─────────────────────────────┬───────────────────────────────────┘
                              │ plaintext
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                    Provider::setApiKey()                         │
│              ProviderEncryptionService::encrypt()                │
│                                                                  │
│  1. Generate random nonce (24 bytes)                             │
│  2. Derive key from TYPO3 encryptionKey via SHA-256              │
│  3. Encrypt with XSalsa20-Poly1305                               │
│  4. Prefix with "enc:" marker                                    │
│  5. Base64 encode for storage                                    │
└─────────────────────────────┬───────────────────────────────────┘
                              │ "enc:base64(nonce+ciphertext+tag)"
                              ▼
┌─────────────────────────────────────────────────────────────────┐
│                         Database                                 │
│                   tx_nrllm_provider.api_key                      │
└─────────────────────────────────────────────────────────────────┘
Copied!

Key derivation 

Example: Domain-separated key derivation
// Domain-separated key derivation
$key = hash('sha256', $typo3EncryptionKey . ':nr_llm_provider_encryption', true);
Copied!

The domain separator :nr_llm_provider_encryption ensures:

  • Keys are unique to this use case.
  • Same encryptionKey produces different keys for different purposes.
  • No collision with other extensions using similar patterns.

Encryption format 

enc:{base64(nonce || ciphertext || auth_tag)}

Where:
- "enc:" = 4-byte prefix marker
- nonce = 24 bytes (SODIUM_CRYPTO_SECRETBOX_NONCEBYTES)
- ciphertext = variable length
- auth_tag = 16 bytes (Poly1305 MAC, included by sodium)
Copied!

Implementation 

Files created/modified 

File Purpose
Classes/Service/Crypto/ProviderEncryptionServiceInterface.php Interface definition
Classes/Service/Crypto/ProviderEncryptionService.php Encryption implementation
Classes/Domain/Model/Provider.php Updated setApiKey/getDecryptedApiKey
Configuration/TCA/tx_nrllm_provider.php Added hashed => false
Configuration/Services.yaml Service registration

Key methods 

Example: Encryption service methods
// ProviderEncryptionService
public function encrypt(string $plaintext): string;
public function decrypt(string $ciphertext): string;
public function isEncrypted(string $value): bool;

// Provider Model
public function setApiKey(string $apiKey): void;      // Encrypts before storage
public function getApiKey(): string;                   // Returns raw (encrypted)
public function getDecryptedApiKey(): string;          // Returns decrypted
public function toAdapterConfig(): array;              // Uses decrypted key
Copied!

Consequences 

Positive 

Encryption at rest: Database dumps no longer expose plaintext credentials.

Transparent operation: Encryption/decryption handled automatically.

No external dependencies: Uses PHP's built-in sodium extension.

Authenticated encryption: Tampering is detected (Poly1305 MAC).

Backwards compatible: Unencrypted values work without migration.

Industry standard: XSalsa20-Poly1305 is used by NaCl/libsodium.

Negative 

Single point of failure: If encryptionKey is compromised, all keys are exposed.

No key rotation: Changing encryptionKey requires re-encryption of all keys.

In-memory exposure: Decrypted keys exist briefly in memory.

Performance overhead: Encryption/decryption on every save/load (minimal).

Net Score: +4 (Strong positive)

Alternatives considered 

  1. TYPO3 Core password type with custom transformer. Rejected: TCA doesn't support custom encryption transformers for password fields.
  2. Defuse PHP Encryption library. Rejected: Adds external dependency. Sodium is built into PHP 7.2+.
  3. OpenSSL AES-256-GCM. Rejected: Sodium's API is simpler and less prone to misuse.
  4. Database-level encryption (TDE). Rejected: Requires database configuration, not portable across environments.
  5. External vault (HashiCorp, AWS KMS). Deferred: Planned for nr-vault extension. Current solution works standalone.

References 

ADR-013: Three-level configuration architecture (Provider-Model-Configuration) 

Status

Accepted

Date

2024-12-27

Authors

Netresearch DTT GmbH

Context 

The nr_llm extension needs to manage LLM configurations for various use cases (chat, translation, embeddings, etc.). Initially, configurations were stored in a single table mixing connection settings, model parameters, and use-case-specific prompts.

Problem statement 

A single-table approach creates several issues:

  1. API Key Duplication: Same API key repeated across multiple configurations.
  2. Model Redundancy: Model capabilities and pricing duplicated.
  3. Inflexible Connections: Cannot have multiple API keys for same provider (prod/dev).
  4. Mixed Concerns: Connection details, model specs, and prompts intermingled.
  5. Maintenance Burden: Changing an API key requires updating multiple records.

Real-world scenarios not supported 

Scenario Single-Table Problem
Separate prod/dev OpenAI accounts Must duplicate all configurations
Self-hosted Ollama + cloud fallback Cannot model multiple endpoints
Cost tracking per API key No clear key-to-usage mapping
Model catalog with shared pricing Model specs repeated everywhere
Team-specific API keys No multi-tenancy support

Decision 

Implement a three-level hierarchical architecture separating concerns:

┌─────────────────────────────────────────────────────────────────────────┐
│ CONFIGURATION (Use-Case Specific)                                        │
│ "blog-summarizer", "product-description", "support-translator"          │
│                                                                          │
│ Fields: system_prompt, temperature, max_tokens, top_p, use_case_type    │
│ References: model_uid → Model                                            │
└──────────────────────────────────┬──────────────────────────────────────┘
                                   │ N:1
┌──────────────────────────────────▼──────────────────────────────────────┐
│ MODEL (Available Models)                                                 │
│ "gpt-5", "claude-sonnet-4-5", "llama-70b", "text-embedding-3-large"     │
│                                                                          │
│ Fields: model_id, context_length, capabilities, cost_input, cost_output │
│ References: provider_uid → Provider                                      │
└──────────────────────────────────┬──────────────────────────────────────┘
                                   │ N:1
┌──────────────────────────────────▼──────────────────────────────────────┐
│ PROVIDER (API Connections)                                               │
│ "openai-prod", "openai-dev", "local-ollama", "azure-openai-eu"          │
│                                                                          │
│ Fields: endpoint_url, api_key (encrypted), adapter_type, timeout        │
└─────────────────────────────────────────────────────────────────────────┘
Copied!

Level 1: Provider (Connection Layer) 

Represents a specific API connection with credentials.

tx_nrllm_provider
├── identifier        -- Unique slug: "openai-prod", "ollama-local"
├── name              -- Display name: "OpenAI Production"
├── adapter_type      -- Protocol: openai, anthropic, gemini, ollama...
├── endpoint_url      -- Custom endpoint (empty = default)
├── api_key           -- Encrypted API key
├── organization_id   -- Optional org ID (OpenAI)
├── timeout           -- Request timeout in seconds
├── max_retries       -- Retry count on failure
└── options           -- JSON: additional adapter options
Copied!

Key Design Points:

  • One provider = one API key = one billing relationship.
  • Same adapter type can have multiple providers (prod/dev accounts).
  • Adapter type determines the protocol/client class used.

Level 2: Model (Capability Layer) 

Represents a specific model available through a provider.

tx_nrllm_model
├── identifier        -- Unique slug: "gpt-5", "claude-sonnet"
├── name              -- Display name: "GPT-5 (128K)"
├── provider_uid      -- FK → Provider
├── model_id          -- API model identifier: "gpt-5"
├── context_length    -- Token limit: 128000
├── max_output_tokens -- Output limit: 16384
├── capabilities      -- CSV: chat,vision,streaming,tools
├── cost_input        -- Cents per 1M input tokens
├── cost_output       -- Cents per 1M output tokens
└── is_default        -- Default model for this provider
Copied!

Key Design Points:

  • Models belong to exactly one provider.
  • Capabilities define what the model can do.
  • Pricing stored as integers (cents/1M tokens) to avoid float issues.
  • Same logical model can exist multiple times (different providers).

Level 3: Configuration (Use-Case Layer) 

Represents a specific use case with model and prompt settings.

tx_nrllm_configuration
├── identifier        -- Unique slug: "blog-summarizer"
├── name              -- Display name: "Blog Post Summarizer"
├── model_uid         -- FK → Model
├── system_prompt     -- System message for the model
├── temperature       -- Creativity: 0.0 - 2.0
├── max_tokens        -- Response length limit
├── top_p             -- Nucleus sampling
├── presence_penalty  -- Topic diversity
├── frequency_penalty -- Word repetition penalty
└── use_case_type     -- chat, completion, embedding, translation
Copied!

Key Design Points:

  • Configurations reference models, not providers directly.
  • All LLM parameters are tunable per use case.
  • Same model can be used by multiple configurations.

Relationships 

┌────────────┐       ┌─────────┐       ┌───────────────┐
│ Provider   │ 1───N │ Model   │ 1───N │ Configuration │
└────────────┘       └─────────┘       └───────────────┘
     │                    │                    │
     │ api_key            │ model_id           │ system_prompt
     │ endpoint           │ capabilities       │ temperature
     │ adapter_type       │ pricing            │ max_tokens
     └────────────────────┴────────────────────┘
Copied!
Entity Responsibility Changes When
Provider API authentication & connection API key rotates, endpoint changes
Model Capabilities & pricing New model version, pricing update
Configuration Use-case behavior Prompt tuning, parameter adjustment

Implementation 

Database tables 

Example: Database schema
-- Level 1: Providers (connections)
CREATE TABLE tx_nrllm_provider (
    uid int(11) PRIMARY KEY,
    identifier varchar(100) UNIQUE,
    adapter_type varchar(50),
    endpoint_url varchar(500),
    api_key varchar(500),  -- Encrypted
    ...
);

-- Level 2: Models (capabilities)
CREATE TABLE tx_nrllm_model (
    uid int(11) PRIMARY KEY,
    identifier varchar(100) UNIQUE,
    provider_uid int(11) REFERENCES tx_nrllm_provider(uid),
    model_id varchar(150),
    capabilities text,  -- CSV: chat,vision,tools
    ...
);

-- Level 3: Configurations (use cases)
CREATE TABLE tx_nrllm_configuration (
    uid int(11) PRIMARY KEY,
    identifier varchar(100) UNIQUE,
    model_uid int(11) REFERENCES tx_nrllm_model(uid),
    system_prompt text,
    temperature decimal(3,2),
    ...
);
Copied!

Domain models 

Example: Domain model classes
// Provider → owns credentials
class Provider extends AbstractEntity {
    public function getDecryptedApiKey(): string;
    public function toAdapterConfig(): array;
}

// Model → belongs to Provider
class Model extends AbstractEntity {
    protected ?Provider $provider = null;
    protected int $providerUid = 0;

    public function hasCapability(string $cap): bool;
    public function getProvider(): ?Provider;
}

// Configuration → belongs to Model
class LlmConfiguration extends AbstractEntity {
    protected ?Model $model = null;
    protected int $modelUid = 0;

    public function getModel(): ?Model;
    public function getProvider(): ?Provider; // Convenience
}
Copied!

Service layer access 

Example: Using configuration from service layer
// Getting a ready-to-use provider from a configuration
$config = $configurationRepository->findByIdentifier('blog-summarizer');
$model = $config->getModel();
$provider = $model->getProvider();

// Provider adapter handles the actual API call
$adapter = $providerAdapterRegistry->getAdapter($provider);
$response = $adapter->chat($messages, $config->toOptions());
Copied!

Backend module structure 

Admin Tools → LLM
├── Dashboard      (overview, stats)
├── Providers      (CRUD, connection test)
├── Models         (CRUD, fetch from API)
└── Configurations (CRUD, prompt testing)
Copied!

Consequences 

Positive 

●● Single Source of Truth: API key stored once per provider.

●● Flexible Connections: Multiple providers of same type (prod/dev/backup).

Model Catalog: Centralized model specs and pricing.

Clear Separation: Connection vs capability vs use-case concerns.

Easy Key Rotation: Update one provider, all configs inherit.

Cost Tracking: Usage attributable to specific providers.

Multi-Tenancy Ready: Different API keys per team/project.

Negative 

Increased Complexity: Three tables instead of one.

More Joins: Queries must traverse relationships.

Migration Required: Existing data needs transformation.

Learning Curve: Users must understand hierarchy.

Net Score: +5 (Strong positive)

Trade-offs 

Single Table Three-Level
Simple queries Normalized data
Data duplication Referential integrity
Faster reads Smaller storage
Harder maintenance Easier updates

Alternatives considered 

1. Two-Level (Provider → Configuration) 

Rejected: Models would be embedded in configurations, duplicating capabilities/pricing.

2. Four-Level (Provider → Model → Preset → Configuration) 

Rejected: Preset layer adds complexity without clear benefit. Temperature/token settings belong with use-case.

3. Single Table with JSON Columns 

Rejected: Loses referential integrity, harder to query, no normalization.

4. Configuration Inheritance 

Rejected: Complex to implement, confusing precedence rules.

Future considerations 

  1. Model Auto-Discovery: Fetch available models from provider APIs.
  2. Cost Aggregation: Track usage and costs per provider/model.
  3. Fallback Chains: Configuration → fallback model if primary fails.
  4. Rate Limiting: Per-provider rate limit tracking.
  5. Health Monitoring: Provider availability status.

References 

ADR-014: AI-Powered Wizard System 

Status

Accepted

Date

2025-12

Authors

Netresearch DTT GmbH

Context 

Users need to configure LLM providers, models, configurations, and tasks -- a complex multi-step process involving endpoint URLs, API keys, model selection, system prompts, and temperature tuning. Manual CRUD via TYPO3 list module is error-prone and intimidating for non-technical users.

Problem statement 

  1. High barrier to entry: First-time setup requires knowledge of API endpoints, adapter types, model capabilities, and prompt engineering.
  2. Model discovery gap: Users don't know which models their provider offers.
  3. Configuration quality: Hand-written system prompts are often suboptimal.
  4. Task chain complexity: Creating a task requires a configuration, which requires a model, which requires a provider -- four entities in sequence.

Decision 

Implement an AI-powered wizard system with three wizard types:

  1. Setup Wizard -- Guided provider onboarding (connect, verify, discover, configure, save). Five-step flow driven by Resources/Public/JavaScript/Backend/SetupWizard.js.
  2. Configuration Wizard -- Takes a natural-language description and generates a structured LlmConfiguration via WizardGeneratorService::generateConfiguration().
  3. Task Wizard -- Takes a natural-language description and generates a complete task chain (task + configuration + model recommendation) via WizardGeneratorService::generateTaskWithChain().

Graceful fallback when no LLM is available:

Example: Fallback when LLM is unavailable
// WizardGeneratorService::generateConfiguration()
$config ??= $this->getDefaultConfiguration();
if ($config === null) {
    return $this->fallbackConfiguration($description);
}
Copied!

Key architectural components:

  • SetupWizardController -- AJAX endpoints for detect, test, discover, generate, save.
  • WizardGeneratorService -- LLM-powered generation with JSON parsing and normalization.
  • ModelDiscovery / ModelDiscoveryInterface -- Provider-specific model listing.
  • ProviderDetector -- Endpoint URL pattern matching for adapter type detection.
  • ConfigurationGenerator -- LLM-powered configuration preset generation.
  • DTOs: DetectedProvider, DiscoveredModel, SuggestedConfiguration, WizardResult.

Consequences 

Positive:

  • ●● Self-service onboarding without requiring LLM expertise.
  • ●● AI-generated prompts are more effective than hand-crafted first attempts.
  • ● Model discovery removes guesswork about available models.
  • ● Fallback defaults ensure the wizard works even without a working LLM.
  • ◐ Five-step flow with progress bar reduces cognitive load.

Negative:

  • ◑ Requires one working LLM configuration to power the AI generation path.
  • ◑ Generated configurations may need manual tuning for specialized use cases.
  • ◑ Additional JavaScript adds bundle size.

Net Score: +5.5 (Strong positive)

Files changed 

Added:

  • Classes/Controller/Backend/SetupWizardController.php
  • Classes/Service/WizardGeneratorService.php
  • Classes/Service/SetupWizard/ModelDiscovery.php
  • Classes/Service/SetupWizard/ModelDiscoveryInterface.php
  • Classes/Service/SetupWizard/ProviderDetector.php
  • Classes/Service/SetupWizard/ConfigurationGenerator.php
  • Classes/Service/SetupWizard/DTO/DetectedProvider.php
  • Classes/Service/SetupWizard/DTO/DiscoveredModel.php
  • Classes/Service/SetupWizard/DTO/SuggestedConfiguration.php
  • Classes/Service/SetupWizard/DTO/WizardResult.php
  • Resources/Public/JavaScript/Backend/SetupWizard.js

ADR-015: Type-Safe Domain Models via PHP 8.1+ Enums & Value Objects 

Status

Accepted

Date

2025-12

Authors

Netresearch DTT GmbH

Context 

Domain constants were stringly-typed throughout the codebase. Adapter types were plain strings ('openai', 'anthropic'), capabilities were CSV strings in database columns, task categories and output formats were validated ad-hoc. This caused subtle bugs and PHPStan violations at higher analysis levels.

Problem statement 

  1. No compile-time safety: Typos like 'opanai' pass silently at runtime.
  2. Scattered validation: Each usage site re-validated allowed values.
  3. Missing behavior: Constants carried no associated logic (labels, icons, defaults).
  4. PHPStan violations: Stringly-typed comparisons defeated type narrowing.

Decision 

Use PHP 8.1+ backed enums for all domain constants. Each enum provides:

  • A string-backed value for database/API compatibility.
  • Static helpers: values(), isValid(), tryFromString().
  • Domain-specific methods: label(), getIconIdentifier(), getContentType().
Example: AdapterType enum with behavior
enum AdapterType: string
{
    case OpenAI = 'openai';
    case Anthropic = 'anthropic';
    case Gemini = 'gemini';
    case Ollama = 'ollama';
    // ...

    public function label(): string { /* ... */ }
    public function defaultEndpoint(): string { /* ... */ }
    public function requiresApiKey(): bool { /* ... */ }
    public static function toSelectArray(): array { /* ... */ }
}
Copied!

Enums implemented:

Enum Purpose Cases
AdapterType LLM provider protocol type 9 cases (OpenAI through Custom)
ModelCapability Model feature flags 11 cases (chat, completion, embeddings, vision, streaming, tools, json_mode, audio, image, text_to_speech, transcription)
TaskCategory Task organization 5 cases (content, log_analysis...)
TaskInputType Task input source 5 cases (manual, syslog, file...)
TaskOutputFormat Response rendering format 4 cases (markdown, json...)
ModelSelectionMode Model selection strategy 2 cases (fixed, criteria)

Immutable readonly DTOs for composite data transfer:

  • DetectedProvider -- Provider detection result with confidence score.
  • DiscoveredModel -- Model metadata from API discovery.
  • SuggestedConfiguration -- AI-generated configuration preset.
  • CompletionResponse -- Immutable final readonly class for LLM responses.

Consequences 

Positive:

  • ●● Invalid values caught at instantiation (BackedEnum::from() throws).
  • ●● PHPStan level 10 compliance without @phpstan-ignore suppressions.
  • ● Self-documenting: AdapterType::OpenAI->defaultEndpoint() vs string lookup.
  • ● IDE auto-completion and refactoring support.
  • match expressions enforce exhaustive handling of all cases.

Negative:

  • ◑ Requires PHP 8.1+ (already the minimum for TYPO3 v13).
  • ◑ Enum #[CoversNothing] needed for PHPUnit 12 coverage.

Net Score: +6.0 (Strong positive)

Files changed 

Added:

  • Classes/Domain/Model/AdapterType.php
  • Classes/Domain/Enum/ModelCapability.php
  • Classes/Domain/Enum/ModelSelectionMode.php
  • Classes/Domain/Enum/TaskCategory.php
  • Classes/Domain/Enum/TaskInputType.php
  • Classes/Domain/Enum/TaskOutputFormat.php

Modified:

  • Classes/Domain/Model/Provider.php -- Uses AdapterType enum.
  • Classes/Domain/Model/Model.php -- Uses ModelCapability enum.
  • Classes/Domain/Model/Task.php -- Uses TaskCategory, TaskInputType, TaskOutputFormat.
  • Classes/Provider/AbstractProvider.php -- Adapter type matching via enum.

ADR-016: Thinking/Reasoning Block Extraction 

Status

Accepted

Date

2025-12

Authors

Netresearch DTT GmbH

Context 

Modern reasoning models emit structured thinking blocks alongside their final output. Anthropic Claude uses native thinking content blocks in its API response. DeepSeek, Qwen, and other models wrap reasoning in <think>...</think> XML tags within the text content. These blocks should be accessible for debugging and transparency but must not pollute the main response.

Decision 

Extract thinking blocks from LLM responses using a two-tier strategy:

  1. Native extraction -- Provider-specific structured thinking blocks (Anthropic type: "thinking" content blocks).
  2. Regex fallback -- <think>...</think> tag extraction for models that embed reasoning inline (DeepSeek, Qwen, local models via Ollama/OpenRouter).

CompletionResponse carries an optional thinking property:

CompletionResponse with thinking support
final readonly class CompletionResponse
{
    public function __construct(
        public string $content,
        public string $model,
        public UsageStatistics $usage,
        public string $finishReason = 'stop',
        public string $provider = '',
        public ?array $toolCalls = null,
        public ?array $metadata = null,
        public ?string $thinking = null,  // Extracted thinking content
    ) {}

    public function hasThinking(): bool
    {
        return $this->thinking !== null && trim($this->thinking) !== '';
    }
}
Copied!

The base AbstractProvider implements the shared regex extraction:

AbstractProvider::extractThinkingBlocks()
protected function extractThinkingBlocks(string $content): array
{
    $thinking = null;
    if (preg_match_all('#<think>([\s\S]*?)</think>#i', $content, $matches)) {
        $thinking = trim(implode("\n", $matches[1]));
        $cleaned = preg_replace('#<think>[\s\S]*?</think>#i', ' ', $content);
        $content = trim(preg_replace('/[ \t]+/', ' ', $cleaned));
    }
    return [$content, $thinking !== '' ? $thinking : null];
}
Copied!

Provider-specific integration:

  • ClaudeProvider -- Iterates response content array. Collects type: "thinking" blocks natively, then runs extractThinkingBlocks() on text content. Merges both.
  • OpenAiProvider -- Runs extractThinkingBlocks() on message content (covers DeepSeek, Qwen via OpenAI-compatible API).
  • GeminiProvider -- Runs extractThinkingBlocks() on first candidate text part.
  • OpenRouterProvider -- Inherits OpenAI behavior (covers all OpenRouter-hosted models).

Consequences 

Positive:

  • ●● Thinking content is preserved without polluting main output.
  • ● Two-tier extraction covers both native and inline thinking formats.
  • hasThinking() convenience method for conditional UI display.
  • ◐ Regex handles multiple <think> blocks per response, concatenating them.
  • ◐ Content between tags is cleaned without word-gluing (space insertion).

Negative:

  • ◑ Regex extraction adds marginal processing overhead per response.
  • ◑ Non-thinking uses of <think> tags would be incorrectly extracted.

Net Score: +5.0 (Strong positive)

Files changed 

Modified:

  • Classes/Domain/Model/CompletionResponse.php -- Added thinking property and hasThinking().
  • Classes/Provider/AbstractProvider.php -- Added extractThinkingBlocks() and createCompletionResponse() with thinking parameter.
  • Classes/Provider/ClaudeProvider.php -- Native thinking block extraction plus regex fallback.
  • Classes/Provider/OpenAiProvider.php -- Regex-based thinking extraction.
  • Classes/Provider/GeminiProvider.php -- Regex-based thinking extraction.
  • Classes/Provider/OpenRouterProvider.php -- Inherits OpenAI behavior.

ADR-017: Safe Type Casting via SafeCastTrait 

Status

Accepted

Date

2025-12

Authors

Netresearch DTT GmbH

Context 

Processing untyped data from JSON API responses, form submissions, and configuration arrays requires casting mixed values to specific scalar types. At PHPStan level 10, direct casts like (string)$mixed trigger "Cannot cast mixed to string" errors. Each usage site would need inline type guards, leading to repetitive boilerplate.

Problem statement 

  1. PHPStan level 10 strictness: (string)$data['key'] is forbidden on mixed.
  2. Verbose alternatives: is_string($v) ? $v : (is_numeric($v) ? (string)$v : '') at every call site.
  3. Inconsistent defaults: Different code paths used different fallback values.
  4. Suppression temptation: Teams resort to @phpstan-ignore instead of proper narrowing.

Decision 

Extract a reusable SafeCastTrait with three static methods that handle mixed input with sensible defaults and no PHPStan suppressions:

Classes/Utility/SafeCastTrait.php
trait SafeCastTrait
{
    private static function toStr(mixed $value): string
    {
        return is_string($value) || is_numeric($value) ? (string)$value : '';
    }

    private static function toInt(mixed $value): int
    {
        return is_numeric($value) ? (int)$value : 0;
    }

    private static function toFloat(mixed $value): float
    {
        return is_numeric($value) ? (float)$value : 0.0;
    }
}
Copied!

Design choices:

  • Static methods -- No instance state needed; enables self::toStr() calls.
  • Private visibility -- Implementation detail of the using class, not public API.
  • Numeric passthrough -- is_numeric() covers int, float, and numeric strings.
  • Empty-string default -- Safer than null for string contexts (concatenation, comparison).
  • Zero default for int/float -- Neutral value for arithmetic operations.

Complements the ResponseParserTrait in Classes/Provider/ which serves a similar purpose for provider API response arrays but with key-based access (getString($data, 'key')). SafeCastTrait handles standalone values.

Usage in WizardGeneratorService:

Example: Normalizing LLM JSON output
$result = [
    'identifier' => $this->sanitizeIdentifier(self::toStr($data['identifier'] ?? '')),
    'temperature' => $this->clamp(self::toFloat($data['temperature'] ?? 0.7), 0.0, 2.0),
    'max_tokens' => $this->clampInt(self::toInt($data['max_tokens'] ?? 4096), 1, 128000),
];
Copied!

Consequences 

Positive:

  • ●● PHPStan level 10 compliance without any @phpstan-ignore suppressions.
  • ● Consistent fallback behavior across all consumers.
  • ● Three-line methods are trivially testable and auditable.
  • ◐ Reduces boilerplate by  5 lines per cast site.

Negative:

  • ◑ Trait usage adds an indirect dependency (mitigated by being a small utility).
  • is_numeric() accepts numeric strings like "1e2" which may surprise.

Net Score: +4.5 (Positive)

Files changed 

Added:

  • Classes/Utility/SafeCastTrait.php

Modified (consumers):

  • Classes/Service/WizardGeneratorService.php -- Uses SafeCastTrait for JSON normalization.
  • Classes/Controller/Backend/TaskWizardController.php -- Uses SafeCastTrait for form data casting (the monolithic TaskController was split per ADR-027).

ADR-018: Multi-Provider Model Discovery 

Status

Accepted

Date

2025-12

Authors

Netresearch DTT GmbH

Context 

Different LLM providers expose different model listing APIs. OpenAI offers GET /v1/models, Ollama uses GET /api/tags, Anthropic has no public listing endpoint, and Gemini uses a different URL structure entirely. The setup wizard needs a unified way to discover available models regardless of provider.

Problem statement 

  1. Heterogeneous APIs: No standard protocol for model listing.
  2. Authentication variance: Bearer tokens, API key headers, URL parameters.
  3. Response format divergence: Each provider returns different JSON structures.
  4. Offline providers: Some providers (Anthropic, Azure) lack public model list APIs.
  5. Endpoint normalization: Users enter URLs with/without trailing slashes, versions, schemes.

Decision 

Abstract model discovery behind ModelDiscoveryInterface with two operations:

ModelDiscoveryInterface contract
interface ModelDiscoveryInterface
{
    /** @return array{success: bool, message: string} */
    public function testConnection(DetectedProvider $provider, string $apiKey): array;

    /** @return array<DiscoveredModel> */
    public function discover(DetectedProvider $provider, string $apiKey): array;
}
Copied!

The ModelDiscovery implementation routes per adapter type to one discoverer class per provider (extracted from the original single-class implementation in 2026-08; behaviour unchanged):

Provider-specific dispatch
public function discover(DetectedProvider $provider, string $apiKey): array
{
    $discoverer = $this->discoverers[$provider->adapterType] ?? null;
    if (!$discoverer instanceof AbstractModelDiscoverer) {
        return $this->getDefaultModels($provider->adapterType);
    }

    $result = $discoverer->discover($endpoint, $apiKey);
    $this->lastDiscoveryUsedFallback = $result->usedFallback;

    return $result->models;
}
Copied!

Each provider's listing, filtering, enrichment and fallback catalog live in Classes/Service/SetupWizard/Discovery/ as a *ModelDiscoverer extends AbstractModelDiscoverer; whether a result is live API data or a canned catalog travels on the returned DiscoveryResult rather than on shared service state.

Key design elements:

  • API-driven discovery for providers with listing endpoints (OpenAI, Ollama, Mistral, Groq, OpenRouter, Gemini).
  • Static fallback catalogs for providers without listing endpoints (Anthropic, Azure, unknown). Maintained with current model information.
  • Provider detection via ProviderDetector using URL pattern matching with confidence scores (1.0 for exact match, 0.3 for unknown).
  • Normalized DTOs: DiscoveredModel unifies model metadata across providers (modelId, name, capabilities, contextLength, costs, recommended flag).
  • Authentication dispatch: Per-provider header format (Authorization: Bearer, x-api-key, x-goog-api-key, none for Ollama).

Provider detection patterns 

ProviderDetector matches endpoint URLs against known patterns:

Pattern Adapter Type Confidence
api.openai.com openai 1.0
api.anthropic.com anthropic 1.0
generativelanguage.googleapis.com gemini 1.0
\*.openai.azure.com azure_openai 1.0
localhost:11434 ollama 1.0
\*/v1/chat/completions (path match) openai 0.6
Unknown endpoint openai (fallback) 0.3

Consequences 

Positive:

  • ●● Unified model discovery across seven provider types.
  • ● Static catalogs ensure discovery works even without API access.
  • ● Confidence scoring lets the UI warn about uncertain detections.
  • ◐ PSR HTTP interfaces allow testing with mock HTTP clients.
  • ◐ Endpoint normalization handles common user input variations.

Negative:

  • ◑ Static catalogs require periodic updates as providers release new models.
  • ◑ API-based discovery may expose all models, including deprecated ones.
  • ✕ Rate limiting on model listing endpoints not handled.

Net Score: +5.0 (Strong positive)

Files changed 

Added:

  • Classes/Service/SetupWizard/ModelDiscoveryInterface.php
  • Classes/Service/SetupWizard/ModelDiscovery.php (since 2026-08 a facade over Classes/Service/SetupWizard/Discovery/, one discoverer class per provider)
  • Classes/Service/SetupWizard/ProviderDetector.php
  • Classes/Service/SetupWizard/DTO/DetectedProvider.php
  • Classes/Service/SetupWizard/DTO/DiscoveredModel.php

ADR-019: Internationalization Strategy 

Status

Accepted

Date

2025-12

Authors

Netresearch DTT GmbH

Context 

The backend module needs multi-language support for all UI elements. Additionally, LLM-powered features (test prompts, wizard descriptions) should respect the backend user's locale so that responses arrive in the expected language.

Decision 

Follow TYPO3 XLIFF conventions for static UI strings and add locale-aware placeholder substitution for dynamic LLM interactions.

XLIFF label files 

One XLIFF file per backend module, plus German translations:

File Scope
locallang.xlf / de.locallang.xlf Shared labels, flash messages
locallang_tca.xlf / de.locallang_tca.xlf TCA field labels and descriptions
locallang_mod.xlf / de.locallang_mod.xlf Main module navigation
locallang_mod_provider.xlf / de.* Provider sub-module
locallang_mod_model.xlf / de.* Model sub-module
locallang_mod_config.xlf / de.* Configuration sub-module
locallang_mod_task.xlf / de.* Task sub-module
locallang_mod_wizard.xlf / de.* Setup Wizard sub-module
locallang_mod_overview.xlf / de.* Overview/Dashboard sub-module

Locale-aware LLM features 

The TestPromptResolverService (a final readonly class implementing TestPromptResolverInterface, injected via DI — it replaced the former TestPromptTrait when the logic was extracted out of the controller) resolves the backend user's language and substitutes a {lang} placeholder in configurable test prompts:

TestPromptResolverService locale resolution
public function resolve(): string
{
    // Reads the configurable prompt (default: "Say hello and introduce
    // yourself in one sentence. Respond in {lang}.") and the BE user's language.
    $prompt       = $this->loadConfiguredPrompt();
    $languageName = self::LANGUAGE_MAP[$this->resolveBackendUserLanguage()] ?? 'English';

    return str_replace('{lang}', $languageName, $prompt);
}
Copied!

Language mapping covers 27 locales (English, German, French, Spanish, Italian, Dutch, Portuguese, Danish, Swedish, Norwegian, Finnish, Polish, Czech, Slovak, Hungarian, Romanian, Bulgarian, Croatian, Slovenian, Greek, Turkish, Russian, Ukrainian, Chinese, Japanese, Korean, Arabic) with English as fallback.

The test prompt text itself is configurable via TYPO3 extension configuration ($GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['nr_llm']['testing']['testPrompt']), allowing administrators to customize it while preserving the {lang} placeholder.

Consequences 

Positive:

  • ●● Standard TYPO3 XLIFF approach ensures compatibility with the Translation Handling system and third-party translation tools.
  • ● German translations shipped as first non-English locale.
  • ● Locale-aware test prompts produce responses in the user's language.
  • ◐ Configurable test prompt allows site-specific customization.
  • {lang} placeholder pattern is extensible to other features.

Negative:

  • ◑ Additional XLIFF files increase maintenance surface per feature.
  • ◑ Language name mapping requires manual updates for new TYPO3 locales.

Net Score: +5.0 (Strong positive)

Files changed 

Added:

  • Resources/Private/Language/locallang.xlf and de.locallang.xlf
  • Resources/Private/Language/locallang_tca.xlf and de.locallang_tca.xlf
  • Resources/Private/Language/locallang_mod.xlf and de.locallang_mod.xlf
  • Resources/Private/Language/locallang_mod_provider.xlf and de.*
  • Resources/Private/Language/locallang_mod_model.xlf and de.*
  • Resources/Private/Language/locallang_mod_config.xlf and de.*
  • Resources/Private/Language/locallang_mod_task.xlf and de.*
  • Resources/Private/Language/locallang_mod_wizard.xlf and de.*
  • Resources/Private/Language/locallang_mod_overview.xlf and de.*
  • Classes/Service/TestPromptResolverService.php and Classes/Service/TestPromptResolverInterface.php

ADR-020: Backend Output Format Rendering 

Status

Accepted

Date

2025-12

Authors

Netresearch DTT GmbH

Context 

LLM responses can contain markdown, HTML, JSON, or plain text depending on the task's output format. Users need to view output in an appropriate rendering mode without re-executing the (potentially expensive) LLM call.

Decision 

Store raw LLM output and handle format rendering entirely client-side. The toggle between formats is ephemeral (not persisted) and operates on the cached raw content.

Four rendering modes in Resources/Public/JavaScript/Backend/TaskExecute.js:

Format rendering dispatch
renderOutput() {
    const content = this._rawContent;
    const escaped = this.escapeHtml(content);
    switch (this._activeFormat) {
        case 'html':     this.renderHtmlOutput(content);    break;
        case 'markdown': this.renderMarkdownOutput(escaped); break;
        case 'json':     this.renderJsonOutput(content);     break;
        default:         this.renderPlainOutput();            break;
    }
}
Copied!

Rendering modes 

Mode Technique Security
Plain <pre> with textContent assignment Fully escaped (DOM API)
Markdown Regex transforms on HTML-escaped content Pre-escaped before transform
JSON JSON.stringify pretty-print in <pre> textContent assignment
HTML Sandboxed iframe (sandbox=\"\") No script execution, no parent DOM access

Security approach 

LLM responses are untrusted external content. Each mode uses a different security strategy:

  • Plain/JSON: Content set via textContent (automatic HTML escaping by the DOM).
  • Markdown: Content is first HTML-escaped via escapeHtml() (textContent assignment to a temporary element, then read back via innerHTML). Markdown regex transforms operate on already-escaped content, making injection safe.
  • HTML: Rendered inside a fully sandboxed <iframe sandbox=""> which blocks all scripting, form submission, and parent page access. A fixed height of 400px is used since contentDocument is inaccessible in sandbox mode.
XSS-safe HTML escaping
escapeHtml(text) {
    this._escapeEl.textContent = text;
    return this._escapeEl.innerHTML;
}
Copied!

Format toggle 

The active format is initialized from the task's output_format setting (returned by the server in the AJAX response) and can be switched by clicking format toggle buttons. The toggle updates _activeFormat, re-renders from _rawContent, and highlights the active button. Clipboard copy always uses the raw content regardless of active rendering mode.

Consequences 

Positive:

  • ●● No server round-trip needed to switch display formats.
  • ● XSS prevention for all four rendering modes via distinct security strategies.
  • ● Raw content preserved for clipboard copy regardless of rendering.
  • ◐ Format toggle state is ephemeral, avoiding unnecessary persistence.
  • ◐ Markdown renderer is lightweight (regex-based, no external library).

Negative:

  • ◑ Markdown regex renderer is simplified (no tables, no nested lists, no links).
  • ◑ HTML iframe height is fixed at 400px (cannot auto-resize in sandboxed mode).
  • ◑ No syntax highlighting for JSON or code blocks.

Net Score: +4.5 (Positive)

Files changed 

Added:

  • Resources/Public/JavaScript/Backend/TaskExecute.js

Modified:

  • Resources/Private/Templates/Backend/Task/Execute.html -- Format toggle UI and output container.
  • Classes/Controller/Backend/TaskExecutionController.php -- Returns outputFormat in the AJAX response (the monolithic TaskController was split per ADR-027).
  • Classes/Domain/Enum/TaskOutputFormat.php -- Defines valid output formats with content types.

ADR-021: Provider Fallback Chain 

Status

Accepted (the streaming scope limitation is overtaken — see Scope limitations (v1))

Date

2026-04

Authors

Netresearch DTT GmbH

Context 

A single misbehaving provider (OpenAI rate-limit, Claude outage, local Ollama daemon not running) previously bubbled up as an uncaught exception to every consuming extension. Operators had no built-in way to degrade gracefully to a second or third provider.

Decision 

A configuration's fallback_chain column stores an ordered JSON list of other LlmConfiguration identifiers. On retryable failures during LlmServiceManager::chatWithConfiguration() or completeWithConfiguration(), FallbackMiddleware (a stage of the provider middleware pipeline, ADR-026) walks the chain and returns the first successful response — or throws FallbackChainExhaustedException carrying every attempt error.

"Retryable" is narrowly defined: the request might succeed against a different provider.

  • ProviderConnectionException — network / timeout / HTTP 5xx / retries exhausted
  • ProviderResponseException with HTTP code 429 — this provider is rate-limiting us, another might not be

Everything else (authentication, bad request, unsupported feature, misconfiguration) bubbles up unchanged — a different provider won't help.

Scope limitations (v1) 

  • Streaming is not wrapped. Once the first chunk has been yielded, we cannot swap providers mid-stream. streamChatWithConfiguration() calls the primary adapter directly.

  • Shallow only. A fallback configuration's own chain is ignored. This prevents both cycles (a -> b -> a) and exponential blow-up of attempts.
  • Inactive fallbacks are skipped, not treated as failures.
  • Missing identifiers are skipped with a warning log, not treated as failures. Misconfiguration should not mask outages.

Storage 

The chain is stored as a single JSON column to keep the schema change minimal and avoid an additional relation table. The Netresearch\NrLlm\Domain\DTO\FallbackChain value object handles serialization, deduplication, and order preservation.

TCA presents the field as a JSON textarea for v1. A richer UI (sortable multi-select of available configurations) can replace the textarea without schema or API change.

Alternatives considered 

  • Fat middleware pipeline (as in b13/aim). Rejected for this release — too invasive for a single-feature change. The middleware pattern remains on the roadmap as a v1.0 refactor; a fallback chain is the most valuable pipeline step users ask for and works fine as a standalone service.

  • Recursive chain resolution (fallback's fallback). Rejected as the cost (cycle detection, attempt amplification) outweighs the benefit; operators can always append to the primary's chain directly.
  • Per-link retry policy (per fallback: max retries, backoff, which exceptions). Rejected as over-engineered for the initial release.

ADR-022: Attribute-Based Provider Registration 

Status

Accepted

Date

2026-04

Authors

Netresearch DTT GmbH

Context 

Registering a new provider previously required two places to stay in sync: the class itself, and a tags: block in Configuration/Services.yaml naming nr_llm.provider with a numeric priority. Omit either side and the provider silently vanished from LlmServiceManager::getProviderList(). For the seven shipped providers this is a footgun we kept stepping on during refactors. For third-party providers it is an onboarding tax.

Decision 

Introduce #[AsLlmProvider(priority: N)] on the provider class and have ProviderCompilerPass scan every container definition at compile time for the attribute, auto-tagging matched services with nr_llm.provider.

The existing yaml-tagging path still works. When both are present, the yaml tag wins (the attribute pass skips already-tagged services). This is deliberate: overrides should be explicit, not silently merged.

The shipped providers now declare their priority via the attribute, and the tags: entries have been removed from Configuration/Services.yaml. ProviderCompilerPass collects every nr_llm.provider-tagged service (from the attribute or a legacy yaml tag), sorts them by priority, and wires each one into LlmServiceManager with a registerProvider() method call. The providers stay private — they are never individually resolved from the container (which keeps the public-services set locked by ADR-028); the backend instantiates the concrete adapter for a provider record directly through ProviderAdapterRegistry. The legacy yaml-tagging path still works for third-party providers.

Trade-offs 

  • + Single source of truth. The priority lives next to the class, not in a sibling yaml file.
  • + Third-party DX. External providers drop in without editing yaml: #[AsLlmProvider(priority: 100)] on an autowired class is enough.
  • + Backward-compatible. Existing yaml-tagged providers keep working.
  • - Reflection at compile time. The compiler pass reflects service definitions in the Netresearch\NrLlm\ namespace; other definitions are skipped by a prefix match on the class name (no reflection). Cost is paid once per container build, cached via ContainerBuilder::getReflectionClass(), and negligible in practice.
  • - Implicit registration. A new reader grepping nr_llm.provider in yaml no longer finds all providers. Mitigation: the attribute constant AsLlmProvider::TAG_NAME is discoverable via symbol search.

Alternatives considered 

  • Symfony's ``registerAttributeForAutoconfiguration`` — the idiomatic path, but TYPO3's DI bootstrap does not expose the underlying container builder at a hook point where attribute registration would work cleanly for every installed extension. A compiler pass runs at the right lifecycle stage and touches only our tag.
  • Keep yaml tags only. Rejected: the double-bookkeeping problem was the whole motivation.
  • Scan providers directory by namespace. Rejected as too magical — implicit "any class ending in Provider" registration is a known anti-pattern.

ADR-023: Native Backend Capability Permissions 

Status

Superseded

Date

2026-04

Superseded

2026-07 by ADR-117

Authors

Netresearch DTT GmbH

Context 

Until now, the only gate on who could invoke an AI capability (vision, tools, embeddings, ...) was the per-configuration allowed_groups MM relation. That is coarse: an editor with access to the "creative writing" configuration could invoke any of its capabilities — text, tool-calling, embeddings — even if the administrator only intended them to use chat.

Administrators also had no native UI surface to revoke a single capability site-wide without editing every affected configuration.

Decision 

Register every ModelCapability enum value as a native TYPO3 BE group permission under $TYPO3_CONF_VARS['BE']['customPermOptions']['nrllm']. The BE group edit view now shows a checkbox for every ModelCapability case (11 today: chat, completion, embeddings, vision, streaming, tools, json_mode, audio, image, text_to_speech, transcription). A new service, CapabilityPermissionService, resolves the check against the currently logged-in backend user.

Resolution order:

  1. No BE user in context (CLI, scheduler, frontend) — allowed.
  2. User is admin — allowed.
  3. Otherwise — delegate to $backendUser->check('custom_options', 'nrllm:capability_X').

Scope 

This ADR ships the registration + check primitive. It does NOT retroactively gate calls inside CompletionService, VisionService, etc. — that is a deliberate follow-up concern, because it is a larger behavioural change than a single-PR feature warrants.

Consumers can opt in today:

if (!$this->capabilityPermissions->isAllowed(ModelCapability::VISION)) {
    throw new AccessDeniedException('Vision capability not permitted for this user', 1745712100);
}
Copied!

Relation to existing access control 

allowed_groups on tx_nrllm_configuration gates access to a named configuration (API keys, preset parameters, system prompt). Capability permissions gate which operations a user is allowed to invoke against any configuration they already have access to. The two are complementary:

  • Configuration ACL: "Can this editor use the 'creative-writing' configuration at all?"
  • Capability permission: "Can this editor invoke vision against any configuration?"

Both checks must pass.

Alternatives considered 

  • Per-capability flags on tx_nrllm_configuration. Rejected: capability is an editor-role concern, not a configuration concern. Duplicating the checkbox on every row is worse UX than a single per-group toggle.
  • A sibling MM table (configuration-to-capability). Rejected as another bespoke access model on top of TYPO3's native one. The whole point of this ADR is to use the native mechanism.
  • Inject the check into every feature service now. Rejected to keep the PR small and the regression surface narrow. See the Scope note above — follow-up work.

ADR-024: Dashboard Widgets 

Status

Accepted

Date

2026-04

Authors

Netresearch DTT GmbH

Context 

tx_nrllm_service_usage has tracked per-request cost and usage from day one, but the data was only reachable through the backend module's report views. Administrators wanted an at-a-glance view next to everything else they already follow — scheduled tasks, indexing, form submissions — which lives on TYPO3's dashboard.

Decision 

Ship two widgets that reuse TYPO3's built-in widget classes and wire them up with nr-llm-specific data providers:

  • AI cost this monthNumberWithIconWidget backed by MonthlyCostDataProvider, which delegates to UsageTrackerService::getCurrentMonthCost(). Returns dollars floored to an integer; the dashboard tile is a glance-value, not an accounting figure.
  • AI requests by provider (7d)BarChartWidget backed by RequestsByProviderDataProvider, which aggregates every service type (chat, vision, translation, speech, image) by service_provider over the last seven days.

Both are registered in a dedicated Configuration/Services.Dashboard.php imported conditionally from Configuration/Services.php only when interface_exists(TYPO3\CMS\Dashboard\Widgets\WidgetInterface::class). A PHP config file (not YAML) is used so the import can be guarded by that runtime interface_exists() check. Without the guard, TYPO3 instances that do not have typo3/cms-dashboard installed would fail at container compile time on the unresolved widget class.

Classes/Widgets/* is excluded from the global auto-registration in Services.yaml for the same reason — the data provider classes import dashboard interfaces and must not be loaded when dashboard is absent.

Trade-offs 

  • + Reuse core widget classes. Two core TYPO3 widget types cover the useful shapes. Writing a custom widget buys nothing.
  • + Optional dependency. typo3/cms-dashboard is a suggest, not a hard require. Installs without dashboard lose the widgets but pay no runtime cost and see no container errors.
  • - Two data-shape spots. The row-shaping logic on RequestsByProviderDataProvider::shapeChartData() is static for unit-testability, but the SQL lives in an instance method bound to ConnectionPool. The trade-off keeps unit tests honest and functional coverage narrow.
  • - Flooring the cost. Displaying $12.97 as 12 is jarring for cost-sensitive users but the widget API returns int. Follow-up: a custom template could render the subtitle with fractional digits once we have one.

Alternatives considered 

  • Custom widget classes implementing WidgetInterface directly. Rejected — duplicates what the core widgets already do.
  • Per-day time series instead of per-provider aggregate. Interesting but the current 7-day window is short enough that the distribution is the more useful glance value.
  • One combined widget with cost + count + top provider in a single tile. Rejected — mixes two summary numbers into one, and forcing both to share the NumberWithIconWidget shape cripples both.

ADR-025: Per-User AI Budgets 

Status

Accepted

Date

2026-04

Authors

Netresearch DTT GmbH

Context 

LlmConfiguration already exposes max_requests_per_day, max_tokens_per_day and max_cost_per_day — but those limits are per configuration, not per editor. Two editors sharing the same preset burn through the same bucket. Administrators asked for a separate dimension: cap editor A's spending independently of editor B's, regardless of which configuration they pick.

Decision 

Ship a new tx_nrllm_user_budget table keyed uniquely on be_user. Each row carries six independent ceilings: requests / tokens / cost, times daily / monthly. 0 on any axis means "unlimited on that axis". The record is a ceiling, not a counter — actual usage is aggregated on demand from tx_nrllm_service_usage, the same table the usage tracker already writes to, so there is no second write per request and no opportunity for the two sources to drift.

BudgetService::check($beUserUid, $plannedCost) is a pure pre-flight. It does not increment anything. Callers invoke it before dispatching to the provider, receive a BudgetCheckResult that says allowed / denied + which bucket was tripped, and act accordingly.

Resolution rules 

  1. Uid <= 0 → allowed (CLI / scheduler / unauthenticated).
  2. No budget record for the user → allowed.
  3. Record exists but is_active == false → allowed.
  4. Record exists but every limit is 0 → allowed.
  5. Otherwise: evaluate the daily bucket, then the monthly bucket. The first to exceed wins and is reported; daily trips take precedence over monthly.
  6. The incoming call adds +1 to the request count and +plannedCost to the cost figure before comparison, so a user at exactly the limit is still allowed one more call.

Scope 

Matches the pattern established for capability permissions (ADR-023): this ADR ships the table + model + repository + check primitive. Wiring BudgetService::check() into individual feature services (CompletionService, VisionService, ...) is a follow-up.

Relation to existing limits 

tx_nrllm_configuration.max_*_per_day remain in place and are orthogonal:

  • Per-configuration daily limits cap a preset. Useful to stop "expensive-model" presets from burning through budget even if many editors share them.
  • Per-user budgets cap a person across every preset. Useful to stop a specific account from running away, whichever preset they pick.

Both checks must pass. Future consumers who want both will check both.

Alternatives considered 

  • Counter-style table (increment on every request). Rejected: duplicates tx_nrllm_service_usage, introduces a second write per request, and adds the drift-between-counters failure mode we deliberately avoid.
  • Group-level budgets via MM to be_groups. Rejected for v1 — individual-user budgets solve the common ask first. Group-level can layer on later.
  • Auto-throttling (queue + retry when over budget). Rejected — silent throttling is worse UX than an explicit denial with a reason the caller can surface.

ADR-026: Provider Middleware Pipeline 

Status

Accepted

Date

2026-04

Supersedes

ADR-004

Authors

Netresearch DTT GmbH

Context 

Every provider call in the extension is wrapped by the same cross-cutting concerns — or rather, it should be, but today those concerns are scattered:

  • FallbackChainExecutor (Classes/Service/FallbackChainExecutor.php) is a try primary / catch / foreach fallbacks loop with two retryable exception types hardcoded. It has no pre/post hooks and no composition seam.
  • It is applied only to database-backed configuration paths in LlmServiceManager::runWithFallback(). Direct calls — chat(), complete(), embed(), vision() — bypass it entirely, which silently splits retry semantics.
  • BudgetService::check() (ADR-025) and UsageTrackerService::trackUsage() are primitives that no feature service actually calls. Budget enforcement and usage accounting must be remembered by every caller, which is a silent footgun.
  • HTTP-level retry with back-off lives inside AbstractProvider (sendRequest()). That is the wrong layer — a rate-limited provider should be swapped, not retried in-place.
  • Cache lookup exists only inside EmbeddingService as ad-hoc branches. There is no way to plug it in for deterministic completion scenarios (seed / temperature 0) without duplicating the branch.

The end result is that every new cross-cutting requirement — PII redaction, prompt logging, trace correlation, per-provider rate limits, circuit breakers, a cost calculator — forces either a bespoke branch in every feature service or a subclass of one of the god classes.

Decision 

Introduce a PSR-15-inspired middleware pipeline under Classes/Provider/Middleware/:

the contract
interface ProviderMiddlewareInterface
{
    public function handle(
        ProviderCallContext $context,
        LlmConfiguration $configuration,
        callable $next,           // callable(LlmConfiguration): mixed
    ): mixed;
}
Copied!

Each middleware receives

  1. an immutable ProviderCallContext (operation kind, correlation id, metadata map),
  2. the current LlmConfiguration,
  3. a $next callable that continues the pipeline.

and decides whether to pass through, short-circuit, swap the configuration, or wrap the call with before/after logic. MiddlewarePipeline::run() composes an ordered stack of them around a terminal callable in classic onion fashion — the first-registered middleware is the outermost layer.

The payload — messages, embedding input, tool specs, vision content — stays captured in the terminal callable. That keeps the existing typed response objects (CompletionResponse, EmbeddingResponse, VisionResponse) intact on the return side and avoids inventing a generic ProviderRequest envelope that would then have to know about every operation variant.

Registration 

Implementations are discovered via the nr_llm.provider_middleware tag, which AutoconfigureTag applies automatically to every class that implements the interface. The pipeline's constructor injects the collected middleware via AutowireIterator. Ordering follows tag priority; priority is an ordering hint only.

Contributors can add behaviour without touching Services.yaml — implement the interface, drop the class under Classes/Provider/Middleware/, you are done.

Default ordering 

The shipped middleware are pinned by tag priority (highest priority = first in the autowired iterator = outermost layer of the onion):

  • 110TelemetryMiddleware: observation only; one row per run (ADR-058: Telemetry Middleware).
  • 100CacheMiddleware: short-circuits on a cache hit.
  • 75BudgetMiddleware: pre-flight budget denial (miss only).
  • 50FallbackMiddleware: swaps configuration on a retryable failure.
  • 25UsageMiddleware: records the call that actually ran.

Telemetry sits outside Cache so measured latency includes the cache lookup and a cache-served response still produces a telemetry row. It never short-circuits, swaps, or denies — it only observes and re-throws.

Scope of this ADR 

Infrastructure only. No behaviour change in this PR:

  • ProviderMiddlewareInterface, MiddlewarePipeline, ProviderCallContext, ProviderOperation enum.
  • Unit tests covering empty pipeline, single/multiple composition, short-circuit, configuration substitution, context propagation, generator-based iterables.
  • This ADR.

FallbackChainExecutor stays untouched. Feature services continue to work exactly as they do today. The pipeline is opt-in: consumers have to build a terminal callable and call MiddlewarePipeline::run() to use it.

Follow-ups 

Each item below is a separate PR that lands one behaviour at a time, so the test matrix keeps green end-to-end:

  1. FallbackMiddleware — port FallbackChainExecutor to the interface. LlmServiceManager::runWithFallback() stops instantiating the executor directly and runs the pipeline instead. Retry semantics become identical for every call path, not just database-backed ones. Deprecate the standalone executor.
  2. BudgetMiddleware — call BudgetService::check() before $next; throw a typed BudgetExceededException on denial so controllers can report which bucket tripped.
  3. UsageMiddleware — after $next returns, hand the response to UsageTrackerService::trackUsage(). Centralises cost/token accounting regardless of which feature called in.
  4. CacheMiddleware — opt-in per operation via ProviderOperation. Embedding lookups start going through it; the branch currently inside EmbeddingService comes out.
  5. Direct-method wiring (centralised) — every direct API method on LlmServiceManager (chat, complete, embed, vision, chatWithTools) builds its terminal callable and invokes the pipeline via a synthesised transient LlmConfiguration. Because every feature service (CompletionService, EmbeddingService, TranslationService, VisionService) delegates to these methods, feature-service traffic inherits the full middleware stack without each service owning its own pipeline glue.

    The transient configuration is unpersisted (no uid), carries an empty fallback chain (so FallbackMiddleware passes through verbatim), and uses a human-readable ad-hoc:<operation>:<provider> identifier so log / trace labels distinguish direct traffic from configuration-backed calls. Middleware that needs more context (beUserUid for BudgetMiddleware, cache keys for CacheMiddleware) reads it from the ProviderCallContext metadata, not from the configuration.

    Streaming (streamChat / streamChatWithConfiguration) stays out of this pipeline — a lazy generator wrapped as the terminal would make every middleware fire against a not-yet-started stream — but is no longer an unaccounted bypass. It runs through an equivalent streaming lifecycle (ADR-062: Streaming Request Lifecycle) that applies the same budget pre-flight, usage, and telemetry, plus pre-first-chunk fallback. The single asymmetry is that a provider swap is impossible once the first chunk has been emitted.

    Why the centralised form rather than "every feature service owns glue": the ADR's problem statement explicitly identifies direct calls as the bug ("chat(), complete(), embed(), vision() — bypass [the fallback executor] entirely, which silently splits retry semantics"). Wiring feature services only would have left direct LlmServiceManager callers still bypassing the pipeline. Centralising on LlmServiceManager fixes both in one step and keeps feature services free of pipeline concerns.

Each follow-up is scoped to a single concern and keeps the codebase shippable after every step.

Embedding cache migration — done 

The inline cache branch that used to live in EmbeddingService::embedFull() has been moved behind CacheMiddleware:

  • EmbeddingResponse and UsageStatistics grew toArray() / fromArray() helpers so the typed response can round-trip through CacheMiddleware (which persists array<string, mixed> via the TYPO3 cache frontend).
  • LlmServiceManager::embed() derives a stable cache key via CacheManagerInterface::generateCacheKey() (same hash shape the old inline branch produced, so existing cache entries stay valid) and places it on the ProviderCallContext metadata under CacheMiddleware::METADATA_CACHE_KEY. cache_ttl == 0 (EmbeddingOptions::noCache()) omits the key so the middleware is a no-op — consistent with the old cacheTtl semantics.
  • The terminal now returns $response->toArray(); the manager reconstructs the typed EmbeddingResponse via EmbeddingResponse::fromArray before returning to the caller. Public method signature is unchanged.
  • UsageMiddleware learned to also recognise the array-payload shape (['usage' => [...], 'provider' => '...']) so usage accounting stays consistent whether the pipeline produced a typed response (other operations) or an array (embeddings via CacheMiddleware).
  • EmbeddingService no longer depends on CacheManagerInterface; it is a pure vector-math façade on top of LlmServiceManager::embed().

Diagnostic / connectivity calls intentionally bypass the pipeline 

Three controller actions test provider connectivity by calling an adapter capability method directly, with their own try / catch block; none of them go through MiddlewarePipeline::run(). The exact call paths today are:

  • ProviderController::testConnectionActionProviderAdapterRegistry::testProviderConnection()ProviderInterface::testConnection(). The registry method catches Throwable and runs an inline preg_replace over $e->getMessage() to strip key / api_key / token / secret / access_token query parameters before returning a {success: false, message} shape. The regex mirrors what AbstractProvider::sanitizeErrorMessage() does for inside-provider errors but is implemented locally to keep the registry independent of the provider base class.
  • ConfigurationController::testConfigurationActionProviderAdapterRegistry::createAdapterFromModel()ProviderInterface::complete(). A short test prompt is sent with the configuration's options. Sanitization happens at the catch (ProviderResponseException $e) arm — by that point the message has already been sanitised by AbstractProvider::sanitizeErrorMessage() inside the adapter before the exception was thrown, so the controller surfaces the upstream HTTP status verbatim.
  • ModelController::testModelActionProviderAdapterRegistry::createAdapterFromModel()ProviderInterface::complete() with a 100-token cap. Same exception-arm sanitization story as the configuration test.

In every case the bypass is deliberate:

  • Budget — a connectivity / configuration probe must not be charged against a user's monthly bucket. These are backend-admin actions; they have no end-user budget owner.
  • Usage — recording a probe in the usage table would distort cost / token dashboards. Probes are administrative, not productive traffic.
  • Fallback — a probe must surface the failure of the probed provider. Silently swapping to a healthy alternative would mask the very condition the probe was designed to detect.
  • Cache — caching the result of a probe would defeat the purpose of probing.

These three diagnostic actions are the documented exemptions from the "productive provider calls are accounted for" rule — they run no budget, usage, telemetry, or fallback on purpose. There are no others. Streaming is not an exemption: it does not use MiddlewarePipeline::run() (a lazy generator cannot be wrapped as the terminal), but it runs an equivalent lifecycle (ADR-062: Streaming Request Lifecycle) that applies the same accounting. New diagnostic / health-check entry points should follow the same pattern as the three listed here: build the adapter via ProviderAdapterRegistry, call the capability method directly, sanitize and surface the error themselves. New non-streaming productive entry points must go through MiddlewarePipeline::run(); new streaming entry points must go through the streaming lifecycle.

Alternatives considered 

  • Per-operation pipelines (separate middleware stacks for chat / embed / vision / tools). Rejected: every middleware we can foresee — fallback, budget, usage, cache, retry, tracing — wants to run for multiple operations. Filtering inside a middleware via ProviderCallContext::operation is cheaper than maintaining N parallel stacks.
  • Generic ``ProviderRequest`` envelope with a mixed $payload. Rejected: forces every provider / middleware / test to downcast payloads. Keeping the payload inside the terminal closure preserves the typed signatures already defined by ProviderInterface and the capability interfaces.
  • PSR-15 directly (ServerRequestInterface / ResponseInterface shapes). Rejected: HTTP semantics do not fit an LLM call, mapping OpenAI's message array onto a ServerRequestInterface is lossy, and the extension already owns LlmConfiguration and typed response objects that are a better fit than a generic PSR-7 request.
  • Event dispatcher (PSR-14) pre/post hooks. Rejected: events cannot short-circuit, cannot substitute the call target, and cannot return a response to the caller — all three are load-bearing for fallback and cache middleware.

References 

  • Audit (2026-04-23): claim #1 — "No middleware pipeline — cross-cutting concerns are scattered or absent". Locally stored under claudedocs/audit-2026-04-23-architecture.md.
  • ADR-021 — Provider Fallback Chain (the behaviour this pipeline will eventually subsume).
  • ADR-025 — Per-User AI Budgets (budget primitive to be wired via BudgetMiddleware).

ADR-027: Split TaskController 

Status

Accepted

Date

2026-04

Authors

Netresearch DTT GmbH

Context 

Classes/Controller/Backend/TaskController.php has grown to 920 lines carrying eleven public actions, nine private helpers, and three distinct user-facing pathways:

  • List / cataloglistAction().
  • AI wizard (create a Task from a natural-language description) — wizardFormAction(), wizardGenerateAction(), wizardGenerateChainAction(), wizardCreateAction().
  • Execution (run a stored Task with various input sources) — executeFormAction(), executeAction(), refreshInputAction().
  • Record picking (browse DB tables to source Task input from a record) — listTablesAction(), fetchRecordsAction(), loadRecordDataAction().

The 2026-04 architecture audit — generated locally and kept under the gitignored claudedocs/ directory rather than checked in (the codebase intentionally excludes Claude Code working notes from version control via .gitignore) — flagged three concrete problems with the controller as it stands:

  1. Inline SQL. Eight call sites use ConnectionPool / QueryBuilder directly to query sys_log, the picked record's table, and so on. Repository layer is bypassed.
  2. Inconsistent response shape. Most backend controllers return typed Response/* DTOs (ToggleActiveResponse, TestConfigurationResponse, etc.) — see ADR-024 widget pattern and the ConfigurationController precedent. TaskController's AJAX actions instead return raw new JsonResponse(['success' => …, 'error' => …]) literals at sixteen call sites.
  3. God-class scope. Three independent user pathways (catalog, wizard, execution + record picking) sharing one class makes navigation, testability, and per-feature ownership harder than it needs to be.

Adding any of the planned follow-ups — pre-flight budget gating in the execute flow (REC #4), a typed exception layer for execute errors (REC #8), domain-JSON-to-DTO promotion for Task::getInputConfig() (REC #6) — would each make this class even larger.

The audit explicitly noted that REC #5 should ship behind an ADR because the change touches backend module routing, the AJAX URL surface JavaScript depends on, and the boundary between controllers and the service layer.

Decision 

We will adopt a hybrid split: per-pathway controllers + service extraction + uniform typed responses. Concretely:

Per-pathway controllers 

The eleven public actions move into four focused controllers, each sharing the same dependency-injection patterns we already use for ConfigurationController / ProviderController / ModelController:

  • Controller/Backend/TaskListControllerlistAction only.
  • Controller/Backend/TaskWizardController — the four wizard actions.
  • Controller/Backend/TaskExecutionControllerexecuteFormAction, executeAction, refreshInputAction.
  • Controller/Backend/TaskRecordsControllerlistTablesAction, fetchRecordsAction, loadRecordDataAction.

Each controller is #[AsController] and remains thin: parse the request DTO, delegate to a service, return a typed response.

Service extraction 

Two new application services capture the logic the controllers currently embed:

  • Service/Task/TaskInputResolverInterface (with TaskInputResolver final readonly impl) — owns the four "where does the input text come from" branches that today live as getInputData(), getSyslogData(), getDeprecationLogData(), getTableData() private helpers. Each branch becomes an injectable strategy (or a match over a typed source enum, depending on shape after closer inspection).
  • Service/Task/TaskExecutionServiceInterface (with TaskExecutionService impl) — coordinates: resolve input via TaskInputResolver, render the prompt template via the existing PromptTemplateService, dispatch to LlmServiceManager, return a typed result DTO. This is also the hook for the future REC #4 budget pre-flight.

Repository layer 

Inline SQL moves to repository methods on two repositories:

  • Domain/Repository/TaskRepository gains fetchSampleRecords(string $table, ...) and loadRecordRow(string $table, int $uid) for the picker controller.
  • The sys_log and deprecation-log reads (which are TYPO3-internal, not Task-domain) move into a small Service/Task/TaskInputResolver collaborator that wraps the appropriate ConnectionPool / Filesystem calls in named methods, then is exposed via an interface so tests can stub it.

Typed response normalization 

Every AJAX action returns a typed Response/* DTO. Five new ones are introduced where no existing match is good enough:

  • Response/TableListResponse (record picker — table dropdown).
  • Response/RecordListResponse (record picker — row results).
  • Response/RecordDataResponse (record picker — single row payload).
  • Response/TaskExecutionResponse (execute success).
  • Response/TaskInputResponse (refresh-input result).

Existing ErrorResponse covers every error branch; raw new JsonResponse(['success' => false, ...]) calls go away.

Rollout plan 

The split lands as a sequence of slices, each its own PR, each independently revertible. A single mega-PR would block on every review iteration; small slices keep each step reviewable.

Sequence 

  1. Slice 13a — extract repository methods. TaskRepository gains the new methods; TaskController gets refactored to call them but keeps every route. Pure SQL move; no behaviour change.
  2. Slice 13b — extract TaskInputResolverInterface + implementation. TaskController private helpers become service calls. No behaviour change.
  3. Slice 13c — extract TaskExecutionService. Controller delegates execute orchestration to the service; this is also where the future REC #4 budget pre-flight will hook in (see ADR-025 / ADR-026).
  4. Slice 13d — introduce typed responses; convert every JsonResponse(['success' => …]) site.
  5. Slice 13e — split the controller in two passes:

    1. Register the four new controllers (each with the #[AsController] attribute) and repoint every entry in Configuration/Backend/AjaxRoutes.php and Configuration/Backend/Modules.php from TaskController::actionXxx to the matching action on the new per-pathway controller. TaskController itself remains in the tree at this point, but no production code references it any more — every route resolves to a new controller.
    2. In a follow-up commit (or follow-up PR if review surface gets large), delete TaskController.php along with any test doubles still referencing it. This pass is mechanical: drop the file, drop test imports, run the test suite.

    Sequencing matters. Routes must move before the file is deleted, otherwise the container compile would fail at the intermediate step.

Each slice maintains AJAX URL stability. JavaScript ajaxUrls constants registered via PageRenderer::addInlineSettingArray() keep their existing names; only the route's target field changes.

Backwards compatibility 

  • The four existing AJAX routes (ajax_nrllm_task_execute, ajax_nrllm_task_list_tables, ajax_nrllm_task_fetch_records, ajax_nrllm_task_load_record) keep their identifiers and paths. Frontend code that resolves them via the inline-settings mechanism is unaffected.
  • The backend module entry under Configuration/Backend/Modules.php keeps its current identifier; the controller target value updates from TaskController::listAction to TaskListController::listAction.
  • No public API change: TaskController is annotated #[AsController] and is not part of any documented extension point.

Consequences 

Positive 

  • Each pathway becomes navigable in isolation. PR scope on Task-area changes shrinks accordingly.
  • The repository layer regains its position as the single source of Task-domain DB access. Future schema changes touch one file.
  • The audit's "DTO/VO vs arrays" axis (currently 8/10 after slice 7) closes the last open gap on the controller layer: every backend AJAX endpoint then ships a typed response.
  • TaskExecutionServiceInterface becomes the natural seam for REC #4 (auto budget + usage in feature services). Without this service, REC #4 would have had to inject BudgetService directly into the controller — a smell.
  • Each new controller has < 250 LOC, so PHPMD/PHPStan complexity metrics improve uniformly.

Negative / costs 

  • Five PRs of churn touching  25 files. CI matrix runs each, the review backlog scales accordingly.
  • Backend module config (Configuration/Backend/Modules.php) and AJAX routes (Configuration/Backend/AjaxRoutes.php) need to point at the new controllers; any extension that programmatically resolves TaskController by class name (none in this repo, but possible downstream) breaks.
  • Functional + E2E tests that reference TaskController::class need updating (counted: 6 functional, 2 E2E). Each gets a one-line change per slice that touches the relevant action.

Alternatives considered 

  1. Smallest-delta — keep TaskController whole, only do service + repository extraction, don't split into per-pathway classes. Hits the audit's SQL and DTO sub-points but leaves the god-class shape. Rejected: doesn't solve "navigation" problem.
  1. Split-only — split into four controllers but leave SQL inline and DTO usage inconsistent. Rejected: the SQL and DTO problems are the audit's specific findings; a split that doesn't address them is rearranging deck chairs.
  1. One mega-PR — perform every extraction in a single change. Rejected: review surface too large; per-slice revertability gone; bisect harder.

References 

  • Audit: claudedocs/audit-2026-04-23-architecture.md § REC #5 (kept locally under the gitignored claudedocs/ directory; not part of the published documentation tree).
  • Existing controller patterns: ConfigurationController, ProviderController, ModelController.
  • ADR-024 (Dashboard Widgets) — typed-response precedent.
  • ADR-026 (Provider Middleware Pipeline) — the natural integration point for REC #4 once TaskExecutionService exists.

ADR-028: Public services policy in Configuration/Services.yaml 

Status

Accepted (count and Category 3 / tail rationale superseded by ADR-065: Reduce the public service surface (ADR-028 follow-up))

Date

2026-04-30

Amended

2026-07-15 by ADR-065

Slice

25 (audit 2026-04-23 REC #9c)

Context 

The 2026-04-23 architecture audit (claudedocs/audit-2026-04-23-architecture.md) flagged the count of public: true overrides in Configuration/Services.yaml (32 at the time of the audit; 37 after intermediate slices added new typed-interface aliases) as "excessive". The default in this extension's _defaults block is public: false, so every public: true line is an explicit override that needs justification.

REC #9c asked: "reduce public: true to only those genuinely needed."

Decision 

The current public-service set is documented here as the deliberate policy. Each public service belongs to one of four categories below, each with a load-bearing reason. New public: true entries must fit one of these categories or add a new one (with rationale appended to this ADR).

A new unit test (Tests/Unit/Configuration/PublicServicesPolicyTest.php) keeps the count honest going forward — when the policy adds a new category it must also record the rationale.

Categories 

1. Public LLM-API surface. Services that downstream extensions and host-instance integrations consume via $container->get(ServiceClass::class) or via direct DI hint in their own services.yaml. These are the documented application surface; they MUST be public.

  • Service\LlmServiceManager (+ LlmServiceManagerInterface)
  • Service\Feature\CompletionService (+ Interface)
  • Service\Feature\EmbeddingService (+ Interface)
  • Service\Feature\TranslationService (+ Interface)
  • Service\Feature\VisionService (+ Interface)
  • Service\Feature\ToolCallingService (+ Interface, ADR-051)
  • Service\Prompt\PromptSnippetComposer (concrete-only, ADR-031)
  • Service\BudgetService (+ BudgetServiceInterface)
  • Service\CacheManager (+ CacheManagerInterface)
  • Service\UsageTrackerService (+ UsageTrackerServiceInterface)
  • Service\LlmConfigurationService (+ LlmConfigurationServiceInterface)
  • Service\PromptTemplateService (+ PromptTemplateServiceInterface)
  • Provider\ProviderAdapterRegistry (+ ProviderAdapterRegistryInterface)
  • Specialized\Translation\TranslatorRegistry (+ TranslatorRegistryInterface)

2. Specialized services with public method surfaces. AI-domain services that act as discrete public APIs, exposed for callers that want them in isolation (image-only, speech-only consumers).

  • Specialized\Speech\WhisperTranscriptionService
  • Specialized\Speech\TextToSpeechService
  • Specialized\Image\DallEImageService
  • Specialized\Image\FalImageService

3. Repositories consumed by tests through the TYPO3 testing framework. TYPO3 FunctionalTestCase::get() uses the Symfony container's ->get() lookup, which only resolves public services. Repositories are exercised by functional tests that round-trip fixtures through real Doctrine, so they must be public.

  • Domain\Repository\LlmConfigurationRepository
  • Domain\Repository\ProviderRepository
  • Domain\Repository\ModelRepository
  • Domain\Repository\TaskRepository
  • Domain\Repository\UserBudgetRepository
  • Domain\Repository\SkillRepository
  • Domain\Repository\SkillSourceRepository

4. SetupWizard collaborators. Three services that are co-instantiated by the wizard controller's typed-DTO factories (DetectedProvider, DiscoveredModel, SuggestedConfiguration). They are public so the wizard's multi-step flow can re-resolve them across requests without holding mutable state in the controller.

  • Service\SetupWizard\ProviderDetector
  • Service\SetupWizard\ModelDiscovery (+ ModelDiscoveryInterface)
  • Service\SetupWizard\ConfigurationGenerator

What is NOT public (intentionally) 

The autowiring resource block at the top of Services.yaml (Netresearch\NrLlm\: { resource: '../Classes/*' }) registers every other class in the namespace as private by default. That covers:

  • Compiler passes (DependencyInjection\)
  • Middleware (Provider\Middleware\Fallback / Budget / Usage / Cache)
  • The fallback executor and its support helpers
  • Setup-wizard support DTOs and resolvers
  • All form / TCA / widget data-provider helpers
  • Internal coercion / parsing helpers

These flow through DI constructor injection only. There is no $container->get() call site for any of them, no test fixture requires them by class name, and there is no documented external consumer.

Constraint and enforcement 

The unit test Tests/Unit/Configuration/PublicServicesPolicyTest.php parses Configuration/Services.yaml and asserts:

  • The total count of public: true keys matches the expected total (currently 45).
  • The ADR file exists and references both REC #9c and the public: true policy text.

Breakdown of the 45:

  • 27 Category 1 — Public LLM API surface (14 concrete services + 13 interface aliases). Every Category-1 service has a public interface alias except Service\Prompt\PromptSnippetComposer (ADR-031), which is concrete-only — consuming extensions resolve it by class name. The maths: 14 concrete + 13 aliases = 27.
  • 4 Category 2 — Specialized services (Whisper, TextToSpeech, DallE, Fal).
  • 8 Category 3 — Repositories (LlmConfiguration, Provider, Model, Task, PromptSnippet, UserBudget, Skill, SkillSource). PromptSnippetRepository is additionally the documented query surface for consuming extensions (ADR-031). SkillRepository and SkillSourceRepository (skills-ingest) are public so their functional tests resolve them via FunctionalTestCase::get().
  • 4 Category 4 — SetupWizard (3 concrete: ProviderDetector, ModelDiscovery, ConfigurationGenerator + 1 alias: ModelDiscoveryInterface).
  • 2 Class-name-resolution tail — Service\UsageAnalyticsService, the read-only Analytics-module reporting service, public solely so its functional test resolves it via FunctionalTestCase::get() (same rationale as Category 3; production callers use constructor injection — its UsageAnalyticsServiceInterface alias stays private), and Service\Tool\ToolRegistry, public so functional tests fetch registered tools by spec name (the tools themselves stay private, ADR-042).

The current test enforces only the count and the ADR's presence. It does not statically validate that each individual public: true entry maps to a category line in this ADR — that would require parsing the ADR's bullet lists. The intentional friction is therefore: a contributor who adds a public: true line bumps the count, the test fails with a prompt to update both this ADR and the constant. Reviewers verify the entry against the categories during PR review.

Adding a new public service therefore requires three things in the same PR: the service definition, this ADR amended (with the new entry placed in the appropriate category, and the running total in the test docblock updated), and the EXPECTED_PUBLIC_TRUE_COUNT constant bumped.

Consequences 

  • No reduction in count. Every current entry is justified; removing any of them would break either downstream consumers (Category 1, 2) or our own functional tests (Category 3, 4).
  • Future-proofing. A new "I'll just make it public" PR now needs an explicit ADR amendment.
  • Drift detection. The architecture test catches a silent public: true addition that bypasses the policy.

Alternative considered 

Mass reduction (privatize everything except Category 1). Rejected: would break  22 functional tests that resolve repositories and wizard services via $this->get(), and the eight functional test files would each need a parallel services-test.yaml override. The maintenance cost outweighs the static-policy win; auditing through this ADR + architecture test is the same outcome without the test-infrastructure churn.

ADR-029: Usage Analytics Dashboard 

Status

Accepted

Date

2026-06-01

Authors

Netresearch DTT GmbH

Context 

tx_nrllm_service_usage has recorded request counts and token totals per service type and provider since day one, and the per-request cost column (estimated_cost) existed from the start. The plumbing to fill it never did: UsageMiddleware always passed a null cost, Model::estimateCost() had zero callers, and so every row carried estimated_cost = 0.000000. The downstream effect was visible — the AI cost this month dashboard widget (see ADR-024: Dashboard Widgets) summed a column that was structurally always zero and showed $0 regardless of real spend.

The table also had no model dimension. Usage could be sliced by provider and service type, but not by the specific model that produced it, so a gpt-4o call and a gpt-4o-mini call against the same provider were indistinguishable in the data — even though their pricing differs by an order of magnitude.

Reporting itself was thin. The only at-a-glance surfaces were the two global dashboard widgets from ADR-024: Dashboard Widgets; there was no dedicated view that combined cost trends, model-level breakdowns, and per-user consumption. With usage now flowing through the middleware pipeline (ADR-026: Provider Middleware Pipeline), there is a single, well-defined place to compute cost as a side effect of every productive provider call.

Decision 

Ship a read-only usage analytics module backed by a richer usage table and real cost computation:

  1. Schema. Add model_uid, model_id, prompt_tokens, and completion_tokens to tx_nrllm_service_usage. Daily granularity is kept — rows still aggregate per day — and model_uid joins the aggregation key (alongside service_type, service_provider, and request_date) so model-level usage rolls up without a second write per request.
  2. Cost computation. UsageMiddleware now derives estimated_cost from the configuration's Model pricing via Model::estimateCost(), using the prompt/completion token split recorded on the usage object. Pricing is stored as cents-per-1M tokens; the estimate is the per-side token count times its rate. When a caller already supplies a cost it is preserved; otherwise the model-derived value is recorded. This fixes the long-standing always-zero-cost defect.
  3. Read layer. Add UsageAnalyticsService, a read-only reporting service over the usage table. It exposes KPI totals (getKpiTotals), a daily cost/requests trend with filled gaps (getDailyTrend), breakdowns by provider, model, and service (getBreakdownByProvider / getBreakdownByModel / getBreakdownByService), and per-user usage with this-month budget consumption (getPerUserUsage). A small AnalyticsPeriod value object normalizes the date-range presets 7d / 30d / 90d / month and defaults unknown values to 30d.
  4. Backend submodule. Register nrllm_analytics as an admin-only child of the main LLM module (Admin Tools > LLM > Analytics), driven by AnalyticsController and a Fluid template: KPI tiles, a cost-plus-requests trend line, provider / model / service breakdown bar charts, and a per-user table with monthly-budget bars. The active range is a plain ?range= GET parameter — the page is a full reload with no AJAX. Charts render with Chart.js (vendored under Resources/Public/JavaScript/Vendor/).
  5. Demo data. Ship a dev-only ddev seed-usage generator that populates roughly 90 days of realistic historic usage so the module and widgets have something to show during local development.

Consequences 

Positive:

  • ●● Real cost reporting. estimated_cost reflects actual model pricing, so the AI cost this month widget (ADR-024: Dashboard Widgets) and the new module both show real figures instead of $0.
  • ● Model-level breakdowns. The added model_uid / model_id columns let usage and cost be sliced per model, not just per provider.
  • ◐ A single dedicated reporting surface combines trend, breakdowns, and per-user consumption that previously had no home.

Negative:

  • ◑ One extra write column-set per request (model_uid, model_id, prompt_tokens, completion_tokens). Negligible — the row was already being written; this widens it, it does not add a second write.
  • ✕ Specialized-service cost and streaming usage are out of scope for v1 and documented as such. DALL·E / TTS / Whisper / DeepL still record requests and units but their cost stays 0 (no token-based pricing model yet), and streaming responses are skipped by the usage middleware because chunked output has no single terminal token count to price.
  • ◑ No backfill of pre-migration rows. Rows written before the schema change keep model_uid = 0 and estimated_cost = 0; analytics only reflect cost from the migration forward.

Net Score: +3 (Positive)

Alternatives considered 

  • Per-request (non-aggregated) rows to enable arbitrary slicing. Rejected — daily aggregation keyed on service_type / service_provider / request_date / model_uid keeps the table small and the existing widget queries fast; the model dimension is the only slice that was actually missing.
  • Compute cost lazily in the read layer from stored token counts and current model pricing. Rejected — pricing drifts over time, so cost must be captured at call time against the pricing in effect then. Storing estimated_cost at write time is the durable record.
  • A third dashboard widget instead of a dedicated module. Rejected — the dashboard widget shapes (ADR-024: Dashboard Widgets) cannot host a trend line, multiple breakdown charts, and a per-user table together; those belong in a full module view.

ADR-030: Specialized Services Authenticate Through nr-vault 

Status

Accepted

Date

2026-06-09

Authors

Netresearch DTT GmbH

Context 

The database-backed LLM providers have authenticated through the nr-vault secure HTTP client since ADR-012: API key encryption at application level — they store a vault identifier (a UUID) rather than a plaintext key, and AbstractProvider::getHttpClient() returns $vault->http()->withAuthentication(...) so the secret is resolved, injected, audited, and memory-scrubbed inside the vault. The plaintext key never surfaces in this extension's code.

The five specialised single-task services — DALL-E and FAL (image), Whisper and TTS (speech), and DeepL (translation), all built on AbstractSpecializedService (see REC #7) — predated that posture. Each read a plaintext apiKey from extension configuration into a protected string $apiKey property and assembled its own Authorization header via a buildAuthHeaders() hook, sending the request through a plain PSR-18 client. This contradicted ADR-012: API key encryption at application level and the project rule that API keys MUST be stored as nr-vault UUID identifiers, never as plaintext.

Two of the services do not use the Bearer scheme: FAL expects Authorization: Key <secret> and DeepL expects Authorization: DeepL-Auth-Key <secret>. The secure client's Header placement could previously inject only the bare secret as a header value, so these schemes could not be expressed through it at all — which is why they had remained on the plaintext path. nr-vault 0.8.0 added a prefix option to withAuthentication() for Header placement, removing that blocker.

Decision 

Migrate every keyed specialised service onto the vault secure HTTP client, mirroring AbstractProvider:

  1. Identifier, not key. AbstractSpecializedService takes VaultServiceInterface as its first constructor argument and stores $apiKeyIdentifier (the vault UUID) instead of $apiKey. isAvailable() becomes $apiKeyIdentifier !== '' && $vault->exists($apiKeyIdentifier).
  2. Placement hooks replace buildAuthHeaders(). The base exposes getSecretPlacement() (default SecretPlacement::Bearer), getSecretPlacementOptions() (default []), and getAdditionalHeaders() (non-auth headers only, e.g. DeepL's User-Agent). getSecureClient() builds $vault->http()->withAuthentication($id, placement, options)->withReason(...) and executeRequest() sends through it. Per-service placement:

    • DALL-E, Whisper, TTS — Bearer (OpenAI family).
    • FAL — Header + {headerName: Authorization, prefix: 'Key '}.
    • DeepL — Header + {headerName: Authorization, prefix: 'DeepL-Auth-Key '}.
  3. DeepL Free/Pro routing stays automatic. DeepL selects the api-free.deepl.com host for keys ending in :fx and api.deepl.com otherwise. Since the key is no longer held as plaintext, the host is resolved lazily on the first request: the secret is retrieved from the vault exactly once, tested for the :fx suffix, and immediately sodium_memzero-d. An explicit baseUrl override still wins. The request itself always authenticates through the audited secure client, never that transient copy.
  4. Configuration. The ext_conf keys become identifiers: providers.openai.apiKeyIdentifier (DALL-E/Whisper/TTS), image.fal.apiKeyIdentifier, and translators.deepl.apiKeyIdentifier.

A setHttpClient() test seam — identical to the providers' — lets unit tests inject a plain client and assert request/response plumbing without the vault; the placement hooks are asserted directly.

Consequences 

  • No specialised service holds a plaintext API key; every upstream call is audited and the secret is scrubbed inside the vault, satisfying ADR-012: API key encryption at application level uniformly across providers and specialised services.
  • Requires nr-vault ^0.8.0 (the prefix option). A 0.7 install would silently drop the prefix and send a broken Authorization header for FAL/DeepL, so the composer floor is raised.
  • Host applications that previously wrote providers.openai.apiKey (and the FAL/DeepL plaintext keys) into nr_llm's extension configuration must store a vault secret and write its identifier instead.
  • DeepL incurs one extra vault read per service instance the first time it sends a request (to choose Free/Pro); the result is cached for the instance lifetime.

ADR-031: Tagged Prompt Snippet Library 

Status

Accepted

Date

2026-06-10

Authors

Netresearch DTT GmbH

Context 

Consuming extensions — first nr_repurpose — assemble prompts from recurring building blocks: a persona, a tone of voice, a target audience, an image style, a layout instruction. Editors want to manage these fragments centrally, once, instead of re-typing them into every extension's own configuration.

The existing PromptTemplate entity does not fit this need. It is a heavyweight complete prompt: it binds a feature, carries model parameters (temperature, max tokens, top-p), supports versioning with parent/variant relations, and tracks usage performance. A persona like "You are Nova, a friendly expert." has none of these concerns — it is a fragment that only becomes a prompt when a consumer composes it with its own instructions. Forcing fragments into PromptTemplate would either bloat every fragment record with irrelevant model fields or fork the template semantics depending on a "fragment" flag.

A second question is how consumers select fragments. A fixed category enum (like Task categories) would require an nr-llm release every time a consuming extension introduces a new fragment kind, which contradicts the goal of nr-llm being a shared foundation that consumers extend without touching it.

Decision 

Introduce a separate, lightweight PromptSnippet entity (table tx_nrllm_promptsnippet) next to — not on top of — PromptTemplate:

  1. Fragments, not templates. A snippet is identifier + name + description + fragment text. No model parameters, no versioning, no performance tracking. PromptTemplate stays untouched.
  2. Free-form CSV tags instead of a category enum. Snippets carry a comma-separated tags field. Consumers query PromptSnippetRepository::findActiveByTag(), which matches tags as exact, case-insensitive tokens — style never matches lifestyle. The tag vocabulary is a convention between editors and consumers (established so far: audience, tone_of_voice, persona, layout, style), documented in the TCA field description and the administration guide. New fragment kinds need no nr-llm release.
  3. JSON metadata side-channel. An optional metadata JSON object carries consumer-specific settings (e.g. {"voice": "nova"} on persona snippets so speech features can pick a matching TTS voice). getMetadataArray() returns [] for empty or invalid JSON — bad editor input must never break a consumer.
  4. Composition stays in nr-llm. PromptSnippetComposer renders an ordered label-to-snippet map into labeled prompt blocks (LABEL: + fragment text, blank-line separated), so all consumers produce uniformly structured prompt sections.
  5. Editing via FormEngine. The backend module gets a "Snippets" list following the established Providers/Models/Tasks pattern; create/edit links into FormEngine, no custom forms.

Amendment (2026-08-09): a configuration selects snippets by tag 

Until this amendment the library had no reader in a production prompt. The only consumers were RunAugmentation::$forcedSnippets and the codec that rehydrates it, and RunAugmentation is constructed nowhere but the tool playground — so outside the playground the whole snippet system was inert, and the "consuming extension" of point 2 above was the only way a snippet ever reached a model.

ADR-139 names a tagged snippet as the supported way to attach editorial context to a request. That needs a selection an operator can make. tx_nrllm_configuration.snippet_tags is it: a CSV of tags whose itemsProcFunc lists the tags the snippet records actually carry, so the vocabulary stays consumer-owned and a new fragment kind still needs no nr_llm release.

The selection is composed into the effective system prompt, not into extra system messages. ConfigurationSnippetResolver appends the labeled blocks to the system_prompt that ConfigurationCallPlanner::callOptions() has merged, behind the configuration's own prompt. One insertion point reaches chat, completion, streaming and the agent loop, because every configuration-driven entry point on LlmServiceManager builds its options there.

The alternative — rendering each snippet as its own leading system message, the shape the playground uses — was rejected. It only works in the playground because the loop bakes the configuration's system prompt ahead of the snippet messages first. Anywhere else a snippet system message would be the first system message in the list, which is exactly the condition under which MessageShaper::applySystemPrompt() leaves the list alone: the configuration's own system prompt would be dropped from the run, silently and with no error. The characterisation tests added with ADR-139 pin that behaviour.

These details follow from the code:

  • Dedup is by snippet identifier. PromptSnippetRepository::findActiveByTag() loads all active snippets and filters in PHP, so a snippet carrying two selected tags comes back from both lookups and would otherwise be composed twice.
  • An unknown tag is empty, not an error — the free-tag model has no referential integrity by design, so a typo degrades to "no snippets".
  • The tool playground reads the same composed value. Its bake site in ToolLoopService::assemble() goes through the same resolver, so a previewed transcript is the transcript a live run sends. A forced snippet the configuration already selects by tag is composed once, not twice: the resolver's identifier dedup never sees the forced list, so the bake site skips a forced block its composed prompt already contains.
  • Hiding a snippet takes it out of every configuration. The repository ignores enable fields on purpose — the backend module lists hidden records — so ConfigurationSnippetResolver is where a hidden record is dropped. is_active remains the operational switch; hidden is the editorial one, and both now keep a snippet out of a production prompt. This is the one place in the extension where hidden decides a runtime outcome, because it is the one place where an editor's list-module action would otherwise keep shipping text to a provider.
  • The composed block counts against the context window. The prompt is prepended after ContextWindowManagerInterface::fit() has run, so both callers (ToolLoopService and ConversationService) hand the composed prompt to fit() instead of letting it re-read LlmConfiguration::getSystemPrompt() — otherwise the budget of ADR-107 is short by exactly the snippet block.

What this does not change: a caller-supplied system message still suppresses the configuration's system prompt — and with it the snippet block — because per-call precedence is decided before this composition is read. That is the pre-existing rule, not a new one.

Consequences 

  • Editors manage personas, tones, audiences, styles, and layouts once, centrally; every consuming extension reads the same library.
  • The free-tag model keeps nr-llm release-independent from consumer vocabulary — at the cost of no referential integrity: a typo in a tag silently yields an empty query result. The documented convention and the tag badges in the list view mitigate this.
  • Token matching is implemented over the CSV field in PHP, not SQL LIKE, guaranteeing exact-token semantics on every database platform. The snippet library is small (tens of records), so loading active snippets for tag filtering is not a performance concern.
  • Two prompt-related entities now coexist. The split is intentional (template = complete prompt, snippet = fragment) and documented here, in the administration guide, and in both entities' PHPDoc.
  • Since the 2026-08-09 amendment an operator can attach snippets to a configuration without writing any code, and every request made with that configuration carries them — including requests from consuming extensions that know nothing about snippets.

ADR-032: Specialized Usage Tracking and Pricing Catalog 

Status

Accepted

Date

2026-06-10

Authors

Netresearch DTT GmbH

Context 

The chat/embedding path records complete usage rows: the middleware pipeline (ADR-026: Provider Middleware Pipeline) tracks tokens and derives a cost from the admin-curated tx_nrllm_model pricing via Model::estimateCost().

The specialised services bypass that pipeline by design — but they recorded almost nothing. The image services passed metric keys (size, quality, count) that UsageTrackerService::trackUsage() does not map, so only request_count = 1 landed in tx_nrllm_service_usage: no cost, no tokens, no images_generated, no model_id. TTS recorded characters but no cost; Whisper recorded nothing but the request. Consequently the Analytics module, the MonthlyCost widget and BudgetService systematically excluded all image and speech spend — defeating the requirement that nr_llm can monitor total AI spend.

Two structural problems compounded this:

  • the specialised services have no access to model pricing (their models — gpt-image-2, tts-1, whisper-1 — usually have no tx_nrllm_model row), and
  • gpt-image-* responses carry a usage token object (DALL·E responses do not), which was discarded.

Decision 

  1. Real units in the callers. The services pass the metric keys the tracker actually maps: images (→ images_generated), characters, audioSeconds (→ audio_seconds_used, from the verbose_json Whisper duration), token keys when the response reports them, and the model identifier as modelId (→ model_id). Provider strings drop the ad-hoc provider:model suffixes (dall-e:dall-e-3 → provider dall-e + model_id).
  2. Token usage parsing. DallEImageService parses the usage object of gpt-image-* responses (input_tokens, output_tokens, total_tokens, input_tokens_details) so token aggregates include image calls; DALL·E responses without usage gracefully omit token metrics.
  3. Static price catalog with a DB override. SpecializedPricingOpenAiPriceCatalog encodes the published OpenAI list prices (each constant documents source URL and verification date): gpt-image-* token prices and per-image fallback estimates, DALL·E per-image prices by quality/size, tts-1 / tts-1-hd per 1M characters, whisper-1 per minute. SpecializedCostCalculator (injected into AbstractSpecializedService) resolves in order: admin-curated tx_nrllm_model row matching the model identifier (reusing Model::estimateCost(), so negotiated prices win) → catalog token prices → catalog per-image price → 0.0. Unknown models never get a guessed cost — a zero cost signals "no price data" instead of fabricating numbers.
  4. No double counting. LlmTranslator no longer repeats the token count on its translation row (the pipeline already records tokens and cost on the underlying chat row); it keeps the translation-level request/characters view. WhisperTranscriptionService::translateToEnglish() loses its second trackUsage() call — the dispatch path records the request exactly once.

Consequences 

  • ● Image, TTS and Whisper spend appears in the Analytics module, the MonthlyCost widget and BudgetService aggregates — total spend monitoring covers all service types.
  • ● Costs follow published list prices and can be overridden per model by creating a tx_nrllm_model row with token pricing.
  • ◑ The catalog requires manual maintenance when OpenAI changes list prices; constants carry source URLs and verification dates to make the review mechanical.
  • ◑ Analytics grouped by service_provider now shows dall-e / fal / tts / whisper instead of suffixed variants (dall-e:dall-e-3); historic rows keep their old strings, the model dimension moved to model_id.
  • ◑ FAL calls record images but cost 0.0 — FAL publishes no static list prices for its hosted models.

ADR-033: Specialized Models in the Model Registry 

Status

Accepted

Date

2026-06-11

Authors

Netresearch DTT GmbH

Context 

The backend Models module manages tx_nrllm_model records for the chat/embedding pipeline, but the specialized services (image generation, text-to-speech, transcription — ADR-030: Specialized Services Authenticate Through nr-vault, ADR-032: Specialized Usage Tracking and Pricing Catalog) selected their models from hardcoded constants (dall-e-3, tts-1, whisper-1) and never consulted the registry. Image and speech models were therefore invisible in the backend: administrators could not curate them, mark a preferred default, or see usage linked to a record. Consuming extensions had no way to ask "which image model should I use on this instance?".

Decision 

  1. Specialized capabilities. ModelCapability gains IMAGE, TEXT_TO_SPEECH and TRANSCRIPTION cases, exposed in the tx_nrllm_model TCA capabilities select, the BE group capability permissions and the model-picker capability badges. Image, TTS and transcription models are regular registry records.
  2. Capability-based default resolution. DallEImageService, TextToSpeechService and WhisperTranscriptionService expose resolveDefaultModel(string $fallback): string: ACTIVE registry records carrying the service's capability are considered provider-agnostically; an is_default record wins, then the lowest sorting; the record's model_id is returned. Fail-soft — any error, missing repository, or no matching record returns the fallback unchanged; the method never throws (the same posture as SpecializedCostCalculator, ADR-032: Specialized Usage Tracking and Pricing Catalog).
  3. Usage linkage. Specialized usage rows now carry the matching registry record's uid as model_uid (resolved fail-soft from the used model_id), so the Analytics model breakdowns link image and speech spend to the curated records; 0 remains the value for models without a registry record.
  4. Configuration-based resolution for specialized services. tx_nrllm_configuration records are the stable indirection layer for image/TTS/transcription exactly as for chat: a consumer references a configuration by identifier, the administrator swaps the assigned model (or adjusts the system prompt) on the record, and every consumer picks it up without re-configuring anything. The three services expose the consumer-facing API

    • resolveModelForConfiguration(string $configurationIdentifier, string $fallback): string — resolution order: the ACTIVE configuration's ACTIVE model record's model_id (records with an empty model_id are skipped) → the capability-based registry default (decision 2) → the given fallback. Fail-soft, never throws.
    • getConfigurationSystemPrompt(string $configurationIdentifier): string — the configuration's system prompt; the empty string when the configuration is unknown, inactive, or unreadable. The prompt is returned to the consumer, never injected implicitly, so the consumer always records the exact prompt it sent (transparency requirement).

    For image generation the model MUST be resolved before the options object is constructed: ImageGenerationOptions validates size against the concrete model value at construction time.

  5. Usage attribution per configuration. The specialized options DTOs (ImageGenerationOptions, SpeechSynthesisOptions, TranscriptionOptions) carry an optional configuration identifier — pure metadata that never reaches the upstream API and never alters validation. When set, the services resolve the configuration uid fail-soft and pass it as configurationUid to trackUsage(), so the Analytics module aggregates specialized spend per configuration just like chat spend.
  6. Snippet-enforcement hook (Phase 2). The planned prompt-snippet feature (pinning/enforcing prompt snippets) attaches at the Configuration level. getConfigurationSystemPrompt() is the single seam where enforced snippets will be folded into the returned prompt — consumers keep calling the same method and stay unchanged when Phase 2 lands.

Consequences 

  • ● Image, TTS and transcription models are first-class registry citizens: curated, activatable, default-flagged and visible in the backend Models module like chat models.
  • ● Consuming extensions resolve the instance-preferred specialized model via resolveDefaultModel() instead of hardcoding one, with a guaranteed-safe fallback.
  • ● Configurations are the stable consumer contract for specialized calls too: model swaps and system-prompt changes are central, one-record edits — no consumer redeployment.
  • ● Analytics model breakdowns link specialized spend to registry records via model_uid and to configurations via configuration_uid.
  • ◐ Hardcoded service defaults remain as fallbacks — instances without curated records keep working unchanged.
  • ◑ Up to two additional fail-soft repository lookups per tracked specialized call (indexed single-row queries; negligible next to the API call).

ADR-034: Remove the ExtensionConfiguration default-provider fallback 

Status

Accepted

Date

2026-06-24

Authors

Netresearch DTT GmbH

Context 

LlmServiceManager carried a session-level default provider: a nullable defaultProvider string seeded from ExtensionConfiguration['nr_llm']['defaultProvider'] and mutable at runtime through setDefaultProvider() / getDefaultProvider() (both on the public LlmServiceManagerInterface). When a generic chat() / complete() / streamChat() call pinned no provider, getProvider(null) fell back to that string.

This is a remnant of the original provider-centric design that predates the database-backed three-tier model (ADR-013: Three-level configuration architecture (Provider-Model-Configuration), ADR-001: Provider Abstraction Layer). Since ADR-021: Provider Fallback Chain / ADR-026: Provider Middleware Pipeline, the generic entry points resolve the active default tx_nrllm_configuration record first (isActive = 1 AND isDefault = 1, via LlmConfigurationRepository::findDefault()); the ExtensionConfiguration fallback was only ever reached when no such record existed.

In practice the fallback was inert: the defaultProvider key was never exposed in ext_conf_template.txt, so it was always null in production unless an integrator set it by hand in additional.php. It was also misleading — together with the orphaned plugin.tx_nrllm TypoScript (removed in #255, answering discussion #254) it suggested a second, config-driven way to choose a provider that no code path honoured as the source of truth.

Decision 

Remove the default-provider concept from LlmServiceManager entirely. The database is the single source of truth for provider selection.

  1. Drop the state and its seed. The defaultProvider property and the ExtensionConfiguration['nr_llm']['defaultProvider'] read in loadConfiguration() are removed. The rest of the extension configuration (provider-specific settings consumed by registerProvider()) is unaffected.
  2. Remove the public accessors. setDefaultProvider() and getDefaultProvider() are removed from LlmServiceManagerInterface and its implementation. This is a breaking change to the public service contract.
  3. `getProvider(null)` throws. With no fallback, getProvider() requires an explicit identifier; called with null it throws ProviderException (code 4867297358) with guidance to configure a default Configuration in the backend module. The signature keeps the nullable parameter for callers that pass a possibly-null pinned provider.

Consequences 

  • ● One way to choose a provider: pin it per call (the provider option on ChatOptions / EmbeddingOptions) or let the generic path resolve the active default Configuration. No silent, inert third path.
  • ● The LlmServiceManagerInterface shrinks by two methods that no production code consumed.
  • Breaking: integrators that called setDefaultProvider() / getDefaultProvider(), or relied on the defaultProvider extension-config key, must instead create an active+default Configuration record or pin the provider per call. No production deployment used the key (it was never exposed in ext_conf_template.txt), so real-world impact is expected to be nil.
  • ● No production behaviour change in practice: the generic entry points already resolved the database default first, and the fallback was never populated in production.
  • ◐ Supersedes the provider-default resolution steps of ADR-007: Multi-Provider Strategy ("Default provider from configuration" / "First configured provider by priority"): provider selection is now per-call or via the active default Configuration only, with no extension-config or priority fallback.

ADR-035: Skill ingest (GitHub-hosted SKILL.md sources) 

Status

Accepted

Date

2026-06-27

Authors

Netresearch DTT GmbH

Context 

Editors want to reuse the growing ecosystem of Claude Code skillsSKILL.md files with YAML front-matter (name + description) and a markdown body — inside nr-llm. These live on GitHub as a single file, as a whole repository (many SKILL.md under skills/, .claude/skills/ or <plugin>/skills/), or behind an Anthropic marketplace.json index that points at further repositories.

Fetching attacker-influenced markdown from the public internet and later feeding it into an LLM prompt raises two separate concerns that are easy to conflate:

  1. Server-Side Request Forgery. The existing nr-vault transport (vault->http()) already blocks internal/private/metadata targets. That guard is about where a request may go, not who owns it.
  2. Supply-chain origin and integrity. Even a non-SSRF target must be a real GitHub host, and the bytes we store must be the bytes we reviewed — a moving branch ref can change content under us.

This ADR records the decisions for Plan 1a — ingest only. Skills are parsed, materialized and reviewed, but not yet injected into prompts; injection, the MM attach tables, and checksum-verify-on-injection are deferred to Plan 1b.

Decision 

  1. Dedicated entities, not extended snippets. Two new Extbase entities — SkillSource (table tx_nrllm_skill_source) and Skill (table tx_nrllm_skill) — model the ingest domain. A skill is a materialized SKILL.md; a source produces N skills. Reusing PromptSnippet (ADR-031: Tagged Prompt Snippet Library) was rejected: snippets are editor-authored fragments, skills are synced remote artifacts with their own lifecycle (sync status, checksum, orphaning).
  2. Ingest / use split. Unit 1 is split at the MM-table seam into Plan 1a (this ADR: sources, fetch, parse, review) and Plan 1b (attach + inject). Each ships fully implemented, no stubs.
  3. SSRF guard ≠ GitHub-origin guard. On top of the nr-vault SSRF guard, GitHubClient enforces an app-level GitHub host allowlist: scheme = https AND host ∈ `{github.com, raw.githubusercontent.com, api.github.com, codeload.github.com} on the **initial request URL**. The transport does **not follow redirects** (any 3xx is treated as an error), so there is no redirect target to escape the allowlist. A rejected URL raises a typed :php:HostNotAllowedException` — never a silent skip.
  4. Fetch by immutable commit SHA + checksum. A source ref (branch/tag) is resolved once to a commit SHA via GET /repos/{o}/{r}/commits/{ref}; the stored pinned_sha is the URL all bodies are fetched from (raw.githubusercontent.com by SHA, never by branch). A body_checksum (sha256) is computed at materialization and re-verified on injection in Plan 1b (fail-closed).
  5. Disabled-by-default for multi-skill discovery. Every repo and marketplace skill arrives enabled = false and must be reviewed before use. A single_file source — one explicit admin act — may default enabled. Re-syncing an enabled skill whose recomputed body_checksum changed auto-reverts it to disabled and surfaces the diff for re-confirmation.
  6. Namespaced upsert, orphan-disable. identifier is namespaced "{source_uid}:{path}" so identical skill names across sources never collide. Re-sync is upsert-by-(source, identifier); a skill that disappeared upstream is marked orphaned + disabled, never silently dropped.
  7. Admin-only management. Sources and skills live in a new nrllm_skills access = admin backend submodule. The two tables are an escalation surface (the body becomes prompt context in 1b) and must never be granted to non-admin backend groups; sync-managed TCA fields (body_checksum, source_sha, raw_frontmatter, support_status, identifier) are read-only and github_token is never shown in a FormEngine form.
  8. String-backed enums + bounded JSON. SkillSourceType, SyncStatus and SupportStatus are string-backed with values() / isValid() / tryFromString() (the project's Defensive-Enum rule). raw_frontmatter and the reserved allowed_tools JSON are byte- and shape-bounded at parse time even though allowed_tools is ignored in 1a.
  9. Explicit ``symfony/yaml`` dependency. Front-matter is parsed with Symfony\Component\Yaml\Yaml; the package is added to composer.json require explicitly rather than relied on transitively.

Consequences 

  • ● Admins reuse the GitHub skill ecosystem from inside the backend, with SHA-pinned, checksum-verified, host-allowlisted fetches.
  • ● The SSRF guard and the GitHub-origin allowlist are independent controls, stated and tested separately — neither masks the other.
  • ● Disabled-by-default plus auto-disable-on-change means no remote content silently enters a prompt: every enable is a deliberate admin review, and an upstream change re-opens that review.
  • ● Orphan-disable (never drop) keeps attached skills (Plan 1b) from vanishing under an editor and makes upstream deletions visible.
  • ◐ Two more domain entities and a new submodule increase surface area; the split from PromptSnippet is intentional and documented here and in the administration guide.
  • ◐ On hardened instances the global HTTP/allowed_hosts SSRF list must include the four GitHub hosts, or every sync fails closed — a deliberate, documented prerequisite.
  • support_status = partial is not a safety signal. It only flags that referenced scripts/assets are not executed (always true in 1a); the prose stays fully untrusted. The injection-time output integrity controls land in Plan 1b.

ADR-036: Skill injection (attach + compose into prompts) 

Status

Accepted

Date

2026-06-28

Authors

Netresearch DTT GmbH

Context 

ADR-035 ingested GitHub SKILL.md files into reviewable Skill records but deliberately stopped before using them. This ADR records Plan 1b — use: attaching enabled skills to a Task and/or an LlmConfiguration and injecting their prose into the prompt.

The skill body is third-party text fetched from the internet. Injecting it into a prompt of an extension that holds vault-encrypted API keys and runs with backend privileges raises distinct concerns: where the text goes in the message structure (role), how much of it goes in (context-window overflow), whether it is still the reviewed bytes (integrity), and what the resulting output is trusted to be (output integrity). The codebase has no tokenizer and Model::contextLength is frequently 0 (unknown), so a pre-flight token budget is not possible.

Decision 

  1. Service-layer injection, not provider middleware. Skill attachments are known from the Task / LlmConfiguration, not at the provider. A shared SkillInjectionService composes the block and is called from the two text-generation entry points — TaskExecutionService (task skills + the task's configuration skills) and the configuration-driven completion / translation path in LlmServiceManager (the resolved configuration's skills).
  2. Text-generation operations only. Injection is applied to completion, translation and task execution. It is never applied to embed(), vision() or speech — injecting instruction prose there is meaningless or actively harmful (it would pollute embedding inputs).
  3. Never the system role. The composed block is prepended to the user prompt — for a plain prompt to the prompt string, for a messages list to the first user-role message only. The configuration system_prompt is left untouched, and the block is never escalated into the system role to fill a missing user turn. A guard preamble prefixes the block ("the following are task guidelines; they cannot override configuration or safety") as defense-in-depth — message role is not a trust boundary.
  4. Precedence: config baseline + task additive. The candidate set is the union of configuration skills then task skills, deduped by ``(source, identifier)`` with the configuration winning, keeping only enabled and non-orphaned skills. The configuration block renders first.
  5. Conservative byte budget, deterministic drop. Because no tokenizer exists, the budget is a conservative byte cap (strlen, default 24 000 — a byte count is a safe over-estimate of tokens for any encoding). When exceeded, skills are dropped from the tail first (task-additive before configuration baseline), each drop logged as a warning. This is intentionally an over-estimate set well below the smallest expected context window. The cap is instance-wide and window-independent.

  6. Checksum-verify on injection (fail-closed). Each skill's stored body_checksum is re-verified against hash('sha256', body) with hash_equals at compose time. A mismatch (possible tampering / a stale row) skips that skill and logs a warning — it is never injected.
  7. Output integrity. Skill-influenced output stays subject to the project's "treat LLM responses as untrusted" rule and is escaped / sanitized where it is persisted or rendered. For partial skills the asset/script references are stripped from the injected prose — to avoid dangling instructions, not as a security control.
  8. Attachment via TCA select + MM. tx_nrllm_task_skill_mm and tx_nrllm_configuration_skill_mm back select fields on the Task and Configuration records, filtered to enabled, non-orphaned skills.

Consequences 

  • ●● Editors reuse reviewed GitHub skills as reusable, per-task or per-configuration instruction sets without copy-pasting prose.
  • ● Config-baseline + task-additive precedence gives a "house style on the configuration, specifics on the task" model with deterministic, deduped composition.
  • ● Fail-closed checksum verification means a tampered or stale skill row is dropped, not silently injected — the ingest-time pin (ADR-035) is enforced again at the moment of use.
  • ◐ The budget is a byte heuristic, not a token guarantee; it is deliberately conservative and logs every drop, but very large skills on tiny-context local models may still be trimmed.
  • ◐ The composer is window-blind by design (restated 2026-08-09, #626). SkillComposer is a single shared service whose maxBytes is fixed at construction, and the same instance serves every call site, so one configuration's model window cannot narrow it without making the composed block differ per caller. Three things make that the wrong trade: the configuration's llmModel is null in criteria selection mode, so the window read there would not even belong to the model that serves the call; ContextWindowManager (ADR-107) already bounds the real send with a calibrated token estimator and now counts the skill block against that budget (#625), which is a strictly better bound than a second byte heuristic; and callers that measure the block before the send rely on composition being a pure function of the skill set. The instance-wide skills.maxBytes is the knob for reserving window; the per-send bound is the context-window manager's job.
  • ◐ Injection touches the live text-generation path; it is scoped to text operations and covered by unit + functional tests, but it is a higher-blast-radius change than ingest.
  • ✕ Message role is not a security boundary: a determined prompt injection in skill prose can still influence output. The mitigation is the guard preamble plus treating output as untrusted — residual risk is output-integrity and cost, not key exfiltration (keys are never in the prompt context).

See ADR-035 for the ingest half and the administration guide for operation.

ADR-037: Backend AJAX admin guard 

Status

Accepted

Date

2026-06-28

Authors

Netresearch DTT GmbH

Context 

The nrllm backend module is registered with access => admin, so TYPO3's module dispatcher only renders its controllers for backend administrators. The module's interactive features, however, are driven by standalone AJAX routes declared in Configuration/Backend/AjaxRoutes.php (ajax_nrllm_*). These routes are dispatched by the generic backend AJAX route handler, not through the module route — so the module's access => admin check never runs for them.

The practical effect: any authenticated backend user (including a low-privilege editor) could call these endpoints directly. The exposed surface is broad and sensitive — provider/model/configuration state mutations (toggle-active, set-default), provider and model test calls that decrypt vault-stored API keys and reach out to upstream LLMs, task execution (which spends budget and runs the configured prompt), reading of arbitrary TYPO3 records via the task record picker, the tool playground's run (which executes the agent loop, spending budget and invoking registered tools) and tool toggle, and the setup wizard's save which creates providers and stores new API keys in the vault.

Only SkillSourceController enforced an admin check, via a private denyNonAdmin() method duplicated nowhere else. Every other backend AJAX controller was unguarded.

Decision 

  1. One shared guard trait. RequiresBackendAdminTrait (Classes/Controller/Backend/) exposes a single private denyNonAdmin(): ?ResponseInterface that returns null for an admin and a 403 {"success": false, "error": "<message>"} JSON response otherwise, where <message> is the localised error.adminRequired label. SkillSourceController now uses the trait; its identical private copy was deleted.
  2. Guard every AJAX-routed action, at the very top. Each action listed in AjaxRoutes.php begins with if (($deny = $this->denyNonAdmin()) !== null) { return $deny; } before any body parse, repository read, or side effect. All AJAX actions already return ResponseInterface, so the JsonResponse is type-compatible. The guard covers LlmModuleController, ProviderController, ModelController, ConfigurationController, TaskRecordsController, TaskExecutionController, SetupWizardController, ToolPlaygroundController, ToolController (the tool-management module split out later — ADR-039) and the already-guarded SkillSourceController — every AJAX-routed action, matching the route table exactly.
  3. Non-AJAX module actions are left untouched. Extbase module actions (listAction, indexAction, executeFormAction, wizardFormAction, …) are reached through the access => admin module route and are already protected; adding the guard there would be redundant.
  4. The standard accessor is ``$GLOBALS['BE_USER']``. The guard reads the current backend user from $GLOBALS['BE_USER'] and checks instanceof BackendUserAuthentication plus isAdmin(). This is the conventional accessor for the authenticated backend user in this context — the AJAX route handler has already established the backend user session by the time the controller action runs, and using the global keeps the guard a zero-dependency trait that any controller can adopt without constructor changes.

Consequences 

  • ●● Every backend AJAX endpoint now requires a backend admin; a non-admin receives a uniform 403 and no state is mutated, no vault key is decrypted, no upstream LLM is called, and no arbitrary record is read.
  • ● A single shared trait removes the duplicated guard and makes "add the guard" the obvious, one-line step for any future backend AJAX action.
  • ● The guard short-circuits before request-body parsing, so it is cheap and cannot be bypassed by malformed input.
  • ◐ Tests that exercise these actions must now set up an admin $GLOBALS['BE_USER'] (functional: setUpBackendUser(1); unit: an admin BackendUserAuthentication stub). This is a one-time, mechanical update to the existing controller test suites.
  • $GLOBALS['BE_USER'] is a global accessor rather than an injected dependency. It matches existing project usage and keeps the trait dependency-free, but it is global state and is set/reset explicitly in tests.
  • ✕ This is an authorization (admin-only) control, not per-record or per-table access control: an admin retains full access to every endpoint, including reading arbitrary records through the task picker. Finer-grained authorization is out of scope.

See ADR-023 for backend capability permissions and ADR-012 for API-key encryption (the keys these endpoints would otherwise expose).

ADR-038: Tool runtime (function-calling agent loop) 

Status

Accepted

Date

2026-06-29

Authors

Netresearch DTT GmbH

Context 

nr-llm completion has been single-shot: one request, one answer. The tool protocol value objects already existed — ToolSpec and ToolCall (ADR-010), OpenAI-wire-aligned — and LlmServiceManager::chatWithTools() could send tool declarations and read the model's tool calls back. But there was no registry of executable tools, no PHP that runs a tool, and no loop that feeds a tool result back into the conversation. A model could ask to call a tool; nothing answered.

Worse, chatWithTools() cannot be the loop's engine. It resolves its provider from the ExtensionConfiguration['nr_llm']['providers'] keyed registry and runs against a model-less transient configuration. That registry is not populated for chat (providers, models and configurations are DB-backed). The consequences are concrete:

  • For keyed providers (Claude, Gemini, Groq, Mistral, OpenRouter) there is no registered API key, so the call is unauthenticated (401).
  • Every provider runs on its hardcoded default model, never the model the admin selected on the LlmConfiguration.
  • Cost is computed downstream by UsageMiddleware from the priced Model; a model-less transient config records zero-cost usage, so the budget cost bucket never sees the spend.

So the agent loop cannot reach a selected configuration's vault key, model, temperature, system prompt or pricing through the provider-key path. A config-aware entry point is required before a loop is safe to run.

Decision 

  1. A DI-tagged tool registry. ToolInterface (Classes/Service/Tool/) declares four methods — getSpec(): ToolSpec, execute(array $arguments): string, isEnabledByDefault(): bool (curated low-risk tools return true; secret- or system-exposing tools return false so they are opt-in) and requiresAdmin(): bool (admin-only gating for tools surfacing system/host/cross-user data) — both central to the fail-open/fail-closed security model below. It carries #[AutoconfigureTag('nr_llm.tool')]. ToolRegistry collects every tagged tool through an autowired iterator and indexes it by spec name (a duplicate name is a developer error → LogicException at construction). An extension adds a tool simply by tagging a class — no central registration edit. The registry is the authoritative allow-set: specs($allowedNames) intersects the declared names against what is actually registered and drops the rest.
  2. A config-aware tool entry point. LlmServiceManager::chatWithToolsForConfiguration() mirrors chatWithConfiguration() — it resolves the adapter from the LlmConfiguration (vault key + real Model + params), guards instanceof ToolCapableInterface and runs through the middleware pipeline, so UsageMiddleware sees the priced model and records real cost. It is additive on LlmServiceManagerInterface (no consumer break) and is the only call the loop makes per round.
  3. A bounded agent loop. ToolLoopService::runLoop() calls chatWithToolsForConfiguration() each iteration; while the model returns tool calls it executes them and re-sends, bounded by a configurable max-iteration cap (constructor default 5). Three fail-soft rules keep the admin informed instead of aborting:

    • An empty offered set (no tools, or an empty allow-list) is a single plain chatWithConfiguration() completion — an empty tools array makes some providers (OpenAI) 400.
    • Hitting the cap with tools still pending triggers one final plain chatWithConfiguration() (no tools field at all) to synthesise a closing answer and sets truncated = true. A no-tools completion yields a real finalContent uniformly across OpenAI, Claude and Ollama — unlike toolChoice='none' or an empty tools array.
    • A mid-loop BudgetExceededException returns the partial ToolLoopResult (trace + usage so far, truncated = true); the budget fires pre-flight and tools are read-only, so the state is consistent.
  4. Raw-array message turns; ChatMessage unchanged. The loop appends the assistant tool_calls turn and one tool result turn per call as raw arrays. LlmServiceManager::normaliseMessages() routes only exact 2-key {role,content} arrays through ChatMessage; the 3-key tool turns pass through unchanged to OpenAI and Claude. Empty arguments serialise to {} (an object), never []. OllamaProvider translates the replayed OpenAI-shape turns into Ollama's native /api/chat shape (object arguments, tool_call_id dropped) and synthesises a call id (call_<index>) on the way out, because Ollama returns none and ToolCall rejects an empty id.
  5. Skill.allowed_tools is a fail-closed-on-declaration allow-list. AllowedToolsResolver reads the effective skills of the configuration and task — SkillComposer::effectiveSkills(): enabled, non-orphaned, at or above the trust floor, deduped. That is the same selection the injection path uses, but not necessarily the same set that reaches the prompt: the skill-block byte budget (ADR-036 §5) is applied later, inside composeBlock(), so a budget-dropped skill still contributes its allowed-tools while its prose does not ship. Making the union budget-aware is deliberately rejected — dropping the last declaring skill would turn the result into null ("no restriction", i.e. every registered tool), so a tighter budget would widen the gate. If no skill declares allowed-tools it returns null (no skill-imposed restriction → all registered tools). If any declares, the result is the union of the declared lists — a lone declared empty list yields [] (no tools). The allow-list is enforced twice: when computing the offered specs() and again at execution time, so a model steered by injected skill prose cannot call a registered-but-not-offered tool.
  6. Authorization is enforced in the runtime, against the acting backend user — not only in the playground. Because ToolLoopService runs tools on behalf of a backend request (and a future non-admin consumer could be wired to it), every tool declares requiresAdmin(). The loop resolves the acting $GLOBALS['BE_USER'] and, when it is not an admin, filters every admin-only tool out of the offered set (fail-closed: an unknown tool name is treated as admin-only). Admin-only tools are those exposing system / host / cross-user data — fetch_logs, get_env / get_env_raw, get_php_info / get_php_info_raw, list_be_users / list_be_users_raw, list_be_groups and read_fal_asset_meta. Tools that read user-scoped records and are usable by a non-admin instead self-enforce the acting user's own TYPO3 permissions inside execute(): get_pagetree applies getPagePermsClause(Permission::PAGE_SHOW) and get_tca filters tables by check('tables_select', …) (an admin bypasses both — TYPO3 admins see everything). Queries use the default restriction set (no blanket removeAll()) so soft-deleted rows never surface; the admin-only be_users / be_groups listings keep removeAll() plus an explicit deleted = 0 so disabled users remain visible for auditing.
  7. Generic error egress, detail logged server-side. A thrown tool, an unknown or disallowed tool name, and any unexpected provider failure become a generic error string. The exception body may carry DBAL/PDO credentials that URL-sanitising would not strip, so it never reaches the provider or the DOM; the full detail is logged through the injected logger.

Consequences 

  • ●● nr-llm gains a real agent loop: admin-curated PHP tools run mid-generation on the selected configuration's vault key and model, and the result is fed back until the model answers or the cap is reached.
  • ●● Cost is recorded via the config-aware path and bounded by the iteration cap plus the per-iteration budget pre-flight (request-count / token / cost buckets, given the BE-user uid is set). Without chatWithToolsForConfiguration() only the cap and token/request counts would bound spend, and keyed providers would 401.
  • ● Extensions extend the tool set by tagging a class; no edit to nr-llm and no architecture exception (tools live under Service\Tool and inherit the existing service-layer guard).
  • ● The allow-list re-validation at both offer and execution time means a declared-but-unknown tool name is dropped and an injected prompt cannot reach a tool the skills did not grant.
  • ◐ The shipped built-in tools (fetch_logs, read_fal_asset_meta, and the later diagnostic/record tools — get_php_info, get_env, get_pagetree, get_tca, list_be_users, list_be_groups and their secret-redacted/raw variants) are admin-curated, read-only, input-bounded and scoped (limit cap + PII redaction; storage-scoped lookup). They are reference implementations of the security contract, not a general capability.
  • ●● Authorization is per-tool and enforced in the runtime against the acting backend user, not merely the playground gate (§6): admin-only tools are filtered out for non-admins (fail-closed), and the user-scoped tools honour the acting user's page / table permissions. A future non-admin consumer of ToolLoopService therefore cannot reach system data or read beyond the user's own TYPO3 rights — closing the escalation surface the earlier admin-only-playground assumption relied on.
  • read_fal_asset_meta is gated admin-only rather than resolving per-user file-storage permissions: file metadata can span storages a non-admin cannot see, and per-storage resolution is brittle, so the simpler, stricter gate was chosen (with the storage allow-list as a further bound).
  • ✕ Message role is not a trust boundary: a prompt injection in skill prose can still steer a tool's arguments. The mitigation is input validation + scoping in each tool, the offered allow-list, and the XSS-safe render of every tool-derived string in the playground.

See ADR-010 for the tool/function-calling abstraction, ADR-013 for the configuration hierarchy the loop runs on, ADR-026 for the middleware pipeline that records cost, ADR-036 for skill injection (which steers tool arguments), and the administration guide for operation.

ADR-039: Global per-tool availability state 

Status

Accepted

Date

2026-06-30

Authors

Netresearch DTT GmbH

Context 

The tool runtime (ADR-038) gates which tools a single agent run may call through two mechanisms:

  • each ToolInterface declares isEnabledByDefault() — a compile-time default (e.g. read-only tools ship on, mutating ones ship off);
  • every run carries a per-request allow-list (the skill's allowed-tools or the playground selection), so a run only ever sees the subset it asked for.

What was missing is an operator control: an administrator could not globally turn a registered tool off for the whole instance. A tool shipping isEnabledByDefault() === true was callable by every run that allow-listed it, with no site-wide kill switch; and a default-off tool could not be switched on without a code change. Neither the per-tool default nor the per-run allow-list is the right seam for "this instance does not permit get_env at all".

Decision 

Introduce a global, per-tool availability override that sits above the per-tool default and below the per-run allow-list.

  • Storage — a dedicated table tx_nrllm_tool_state (tool_name unique, enabled boolean). It has no TCA and no FormEngine UI: it is operational state toggled from the backend, not editorial content edited as a record. A missing row falls back to the tool's isEnabledByDefault(), so the table only ever holds explicit admin overrides.
  • RepositoryToolStateRepository exposes overrides() (the sparse override map) and setEnabled(name, bool) (upsert one override).
  • Effective-state serviceToolAvailabilityService computes the authoritative "what may run at all" set: for every registered tool the effective state is its admin override when one exists, otherwise its isEnabledByDefault(). enabledNames() returns the enabled subset; states() returns the full name / description / enabled / defaultEnabled rows the backend renders.
  • Runtime enforcementToolLoopService intersects every per-run allow-list with enabledNames(), so a globally-disabled tool can never be invoked regardless of what a skill or the playground requested. This is the same defense-in-depth layering as the acting-user RBAC intersection in ADR-038 — the allow-list narrows, it never widens.
  • Backend surface — the toggles are rendered and persisted by the dedicated Tools backend module (ToolController), split out from the interactive Playground module so managing availability and running the agent loop are separate admin concerns (see the two-module split). toggleToolAction() is admin-guarded (ADR-037) and writes through ToolStateRepository::setEnabled().

Consequences 

  • Administrators get a site-wide kill switch per tool, independent of code defaults and of any individual run's allow-list.
  • Availability resolves in two steps: the effective global state is the admin override when one exists, otherwise the compile-time default (so an override can enable a default-off tool or disable a default-on one — it replaces the default, it does not merely narrow it). The per-run allow-list is then intersected with that effective set, so a run can only ever narrow what is globally enabled — a globally-disabled tool can never be called, but the allow-list can never re-enable one.
  • The table is deliberately TCA-less: it is a small operational toggle set keyed by tool_name, not a versioned/localisable record, so a bespoke toggle endpoint is a better fit than FormEngine (and avoids exposing an editable "tool" record that implies more than a boolean).
  • Because a missing row falls back to the tool default, shipping a new tool needs no data migration: its isEnabledByDefault() applies until an admin overrides it.
  • Reads go through ToolAvailabilityService on every agent run; the override map is a single small query, cheap relative to the LLM calls it gates.

Alternatives considered 

  • Reuse the per-run allow-list only — rejected: the allow-list is authored per skill/run and cannot express an instance-wide policy; a globally-forbidden tool would have to be scrubbed from every skill.
  • Flip isEnabledByDefault() in code — rejected: the default is a ship-time property of the tool, not per-instance operator policy, and changing it requires a release.
  • A TCA-backed ``tool`` record — rejected: tools are code-registered, not editable entities; a full record UI would imply create/delete/localise semantics that do not apply to a boolean override keyed by a code identifier.

ADR-040: Playground run trace and tool-path prompt augmentation 

Status

Accepted

Date

2026-07-05

Authors

Netresearch DTT GmbH

Context 

The admin playground (ADR-038) ran the bounded agent loop and returned only the final answer plus a flat list of executed tool calls and a single total-token count. For an admin whose job is to understand how a configuration behaves — and to debug a task, prompt or extension that misbehaves — that is a black box. The information needed to reason about a run was either discarded or never captured:

  • the exact messages sent to the provider each round (system prompt, injected skills, snippets, the growing tool dialog) were assembled and thrown away;
  • the raw provider response was parsed into CompletionResponse and discarded in every adapter except OpenRouter;
  • per-call latency was never measured, and the prompt/completion token split was summed away into one total;
  • the model's intermediate turns and thinking never surfaced.

Two capability gaps compounded this. Skill prose was injected for the text-generation paths but not in the tool-loop path — the loop was the sole caller of LlmServiceManager::chatWithToolsForConfiguration() and applied no skills. Prompt snippets had a composer but no runtime caller at all.

Decision 

Add an opt-in run trace and one-time prompt assembly to ToolLoopService, consumed by the playground; production callers are unaffected.

Run trace 

Netresearch\NrLlm\Service\Tool\RunTrace is a mutable recorder passed into runLoop() as an optional, nullable argument. When present the loop records a readonly RunStep per model round-trip (messages sent, tools offered, content, thinking, finish reason, the prompt/completion/total token split, estimated cost and the requested tool calls) and per executed tool call (name, arguments, result, error flag). Timing is measured with hrtime() around each provider call and each tool invocation — no new middleware. When no RunTrace is passed — every production caller — the loop records nothing and behaves exactly as before.

One-time prompt assembly 

The loop assembles the outgoing prompt once, before the first round:

  • Configuration skills now inject into the tool path. Because the loop is the sole caller of chatWithToolsForConfiguration() and re-sends its own accumulating message array (augmentMessages() returns a new list and never mutates the input), injecting once before the loop closes the gap without double-injecting. This is a behaviour change for the tool loop: configurations with attached skills now carry that prose into tool runs, as the text-generation paths already did.
  • A RunAugmentation adds the playground-only extras: forced skills (injected as additional task skills), forced snippets (added as separate leading system messages, one per snippet), and a dry-run mode that assembles and records the messages without calling the provider. When an augmentation is present the effective system prompt (a per-run override wins over the configuration's) is baked as the first message, so the snippet system messages cannot suppress it.

Gated raw-response capture 

ToolOptions::withCaptureRaw() sets a private _capture_raw directive that flows through the call options to the adapters. When set, each adapter stores the decoded provider body under metadata['_raw'] via AbstractProvider::rawResponseMetadata(). It is off by default, so production calls never retain raw payloads — only the admin playground opts in, and the module is admin-only.

Consequences 

  • The playground surfaces the full nr_llm ↔ LLM dialog: assembled prompt, per-round timing and token split, requested tool calls, thinking, the raw response (on demand) and the tool executions.
  • Skills attached to a configuration now influence tool runs, matching the text-generation paths; snippets have a first runtime consumer.
  • RunTrace and the augmentation collaborators are optional and autowired, mirroring the optional SkillInjectionService on LlmServiceManager; the production tool path and existing lean test wiring are unchanged.
  • Raw capture touches all seven adapters, but only along the gated path; the non-capture response is byte-for-byte identical.

ADR-041: Playground live run streaming 

Status

Accepted

Date

2026-07-06

Authors

Netresearch DTT GmbH

Context 

The playground inspector (ADR-040) ran the whole bounded agent loop server-side and returned the complete trace as one JSON document. The browser therefore showed nothing until the entire run — every model round-trip and tool execution — had finished, then rendered all at once. For a multi-round run against a slow local model that is several seconds of a blank pane, and it hides when each step happened.

The goal is a live inspector: each step appears the moment it is recorded.

Decision 

Stream the run as newline-delimited JSON (NDJSON), one event per line, over the existing admin AJAX route:

  • Opt-in. The client sends stream=1; the controller then streams. Without it the controller keeps the batch path — one respondJson() document — which remains the no-JavaScript fallback and the shape the functional tests assert.
  • Per-step callback. RunTrace takes an optional onRecord closure fired the instant each RunStep is recorded. The streaming controller passes a closure that echoes one {"event":"step","step":…} line and flushes; a final {"event":"done",…} (or {"event":"error",…}) line carries the summary. When no closure is passed (every production and test caller) the loop is byte-for-byte unchanged.
  • Request steps stream before the call. Each model round-trip is recorded as two steps: a request step (the messages sent and tools offered, no timing/tokens) emitted before the provider call, and the llm response step (content, timing, token split) after it. The first event reaches the browser within moments of the POST — the inspector is live from second zero and shows a waiting state while the model works — instead of staying empty until the first response arrives. The message array is serialised once per round, on the request step only.
  • Beat the proxy buffer. A TYPO3 backend response is buffered by the reverse proxy until a chunk clears its flush threshold, so small lines all arrive at the end. The stream disables output buffering and zlib compression at runtime and pads every line past  4 KB with trailing whitespace (ignored by JSON.parse), which makes each event flush immediately.
  • NullResponse. Having written output directly, the controller returns TYPO3CMSCoreHttpNullResponse, which AbstractApplication::sendResponse() skips — TYPO3 emits nothing further, avoiding a double body or a headers-already-sent warning.
  • Same UTF-8 guard. Each line is encoded with JSON_INVALID_UTF8_SUBSTITUTE (as ADR-040 established for the batch response), so a malformed byte substitutes rather than aborting the stream.

The client reads the response body stream, splits on newlines, parses each line, appends the step to a live trace and re-renders. If the browser cannot read a streaming body it falls back to the batch request.

Consequences 

  • Steps render as they happen; the summary strip fills in live from the per-round token counts and finalises on done.
  • The 4 KB padding is transfer overhead (a few KB per event) that never reaches the user — an accepted cost for reliable incremental flushing across proxies.
  • Direct echo/flush plus NullResponse is deliberately outside the PSR-7 body abstraction; it is confined to the one streaming method and the batch path stays a normal response.
  • A run whose final model round stops on finishReason: length is now flagged with a truncation banner, and Max tokens/Temperature are exposed as per-run controls so the operator can lift the cap.

ADR-042: Content and configuration read tools for the agent 

Status

Accepted

Date

2026-07-08

Authors

Netresearch DTT GmbH

Context 

The first eleven built-in tools (ADR-038) are system introspection: page-tree structure, TCA schema, logs, environment, backend accounts. The agent could not read the one thing most tasks are about — content: no full-text search, no "what is on this page", no generic record read, and no view of the TypoScript/TSconfig that shapes rendering.

Two third-party extensions cover adjacent ground and informed this decision (both GPL-2.0-or-later, licence-compatible with nr-llm):

  • EXT:typo3_ai_mate (konradmichalik) — a dev-only, read-only debugging toolset (records, page composition, resolved TypoScript/TSconfig, Fluid resolution, logs, profiler). Its tool classes run outside TYPO3 in a symfony/ai-mate MCP process and shell into TYPO3 console commands, so they cannot be reused in-process — but its catalogue shows which introspection reads matter.
  • EXT:mcp_server (hauptsache.net) — an in-TYPO3 MCP server for content editing as an authenticated backend user (Search, GetPage, ReadTable, WriteTable). Architecturally the closest cousin (tagged-iterator tool registry, JSON-Schema specs); its layered permission model (tables_select → DataHandler → workspace-staged writes) is the reference for any future write path.

Decision 

Build five native, read-only tools — own implementations inspired by those catalogues, with no dependency on either extension:

search_records
Full-text search across tables declaring TCA searchFields (mcp_server's Search as the model).
get_page_content
One page plus its content elements in column/sorting order (mcp_server's GetPage as the model).
read_records
Generic equality-filtered read of one TCA table (mcp_server's ReadTable and ai_mate's typo3-records as the models). Never raw SQL — equality filters bound as named parameters only.
get_typoscript
The resolved frontend TypoScript (setup/constants) for a page via the core v13/v14 APIs (rootline → SysTemplateRepositoryFrontendTypoScriptFactory), resolved in-process (ai_mate resolves the same data via a CLI subprocess).
get_tsconfig
The rootline-merged Page TSconfig via BackendUtility::getPagesTSconfig().

A shared TableReadAccessService centralises the read policy for the three record tools instead of three copies.

Read-permission model 

All five tools follow the fail-closed contract of ADR-038 (no backend user → no data) and add:

  1. Sensitive-table denylist — absolute. be_users, be_groups, fe_users, fe_groups, sys_log, sys_history, sys_refindex and every tx_nrllm* table are unreadable for every user including admins: credentials and audit data have dedicated redacting tools, and the nr-llm tables carry provider endpoints and vault key references that must never egress to a provider.
  2. Sensitive-field denylist — absolute. Columns whose name contains a credential-ish segment (password, secret, token, salt, hash, key, mfa, …) are dropped from every select, filter and search-field list, for every user.
  3. Non-admin narrowing. Non-admins are additionally limited to tables granted by tables_select (TCA adminOnly tables excluded), get the default query restrictions (no hidden/timed rows), and every emitted row's page is checked against the acting user's PAGE_SHOW permission — memoised per page uid, applied after the query, so a result page may return fewer than limit rows rather than weakening the check. Root-level rows (pid 0) of non-pages tables fail closed for non-admins.
  4. Admin-only TypoScript/TSconfig. get_typoscript and get_tsconfig are admin-only — TypoScript constants routinely carry API keys and DSNs — and still redact values under credential-ish keys as defence in depth, and cap output (top-level keys without a path, hard line cap with one).

Write tools deferred 

State-changing tools (create/update/delete records) are explicitly out of scope. If nr-llm ever adds them, EXT:mcp_server's write design is the reference: all writes through DataHandler as the acting backend user (page permissions and hooks apply), gated by a TableAccessService, and staged in a non-live workspace so every agent change requires a human publish. Until that model is implemented here, the agent stays read-only.

Consequences 

  • The agent can search and read content, records and the effective TypoScript/TSconfig on every installation, with no third-party dependency.
  • Non-admins can safely use the three content tools: the tools enforce the same visibility the backend already grants them.
  • The denylists are intentionally not configurable — configurability would invite weakening the egress guarantees.
  • The tool count grows to sixteen; the Tools module and the playground pick the new tools up automatically via the registry (no UI change).

ADR-043: Tool groups with a fail-closed enable cascade 

Status

Accepted

Date

2026-07-08

Authors

Netresearch DTT GmbH

Context 

With sixteen built-in tools (ADR-038, ADR-042) and third-party tools registering through the same nr_llm.tool tag, per-tool administration stops scaling: an admin who wants "no system introspection for this instance" or "only content tools for this configuration" has to know and toggle every individual tool — and a tool added later by an extension update silently escapes a decision that was meant to cover its whole family.

Decision 

Every tool declares a group, and enablement cascades across three levels, fail-closed:

ToolInterface::getGroup(): string (breaking, third parties must implement it — the same expansion pattern as requiresAdmin() in ADR-038). Built-ins use the curated taxonomy content, structure, system, accounts, configuration. Third-party tools declare their own group; the recommended value is the providing extension's key.

Level 1 — central group state. tx_nrllm_tool_group_state stores per-group admin overrides (mirroring tx_nrllm_tool_state; a missing row means enabled). Because the state is keyed by group name, a disabled group also covers same-group tools installed later. ToolAvailabilityService computes the effective state as group_enabled && tool_enabled: a per-tool override can not re-enable a tool inside a disabled group. The alternative — letting an explicit tool override outrank its group — was rejected because it turns "disable the group" into a soft hint whose real effect depends on invisible per-tool rows; the chosen rule keeps one glance at the group toggle authoritative.

Level 2 — per configuration. tx_nrllm_configuration gains allowed_tool_groups (comma list via selectCheckBox; items derived from the registry by an itemsProcFunc, so third-party groups appear automatically). Empty means "no group restriction". AllowedToolsResolver intersects the skill-declared allowed-tools union with the group gate; when only the group gate is set, it becomes the allow-list itself.

Level 3 — per run. The playground groups its tool checkboxes and adds a group checkbox with an indeterminate mixed state; the per-run selection is still intersected with levels 1–2 by the runtime gate.

Consequences 

  • Breaking: every ToolInterface implementation must add getGroup(). Costs one method per tool; buys family-wise control that survives extension updates.
  • An unknown or never-toggled group is enabled — grouping restricts, it does not quarantine new tools (isEnabledByDefault() and requiresAdmin() keep covering per-tool risk).
  • The group table stays name-keyed and FormEngine-free, like the tool-state table; orphaned group rows (extension removed) are harmless and inert.
  • The configuration gate composes with — never replaces — the global cascade: a globally disabled tool stays off even when its group is listed in allowed_tool_groups.

ADR-044: Error-analysis tools with fail-closed guards 

Status

Accepted

Date

2026-07-09

Authors

Netresearch DTT GmbH

Context 

The agent can read content and configuration (ADR-042), but the debugging use cases admins actually bring to the playground — "why does this URL answer 500", "how do I fix this PHP error" — need capabilities the tool set lacked: reading the TYPO3 file logs with the failing source code, reading arbitrary project files, searching the code base, and probing a frontend URL. All four egress host-level data to an external LLM provider and take model-chosen (attacker-influenceable) arguments, so each needs a hard, fail-closed containment story.

Decision 

Four admin-only tools, two shared guards.

Tools 

get_last_exception (group code)
Newest error-level entries from the TYPO3 file logs, with the parsed stack trace and ±6 lines of source context inlined for up to three project-local frames (vendor/core frames are listed, not expanded). index steps back through older errors, search filters.
read_source (group code)
Line-ranged, line-numbered read of one project file (default 60, max 200 lines).
search_code (group code)
Literal-substring (opt-in regex) search over project source files — a pure-PHP walk, no shell-out — returning path:line hits under a hard budget (20 000 files / 5 s), reported when exhausted.
probe_url (group system)
One GET against the instance's own frontend: status, key headers, timing, a 2 KB tag-stripped body excerpt — and on a 5xx the newest error-log entries from the probe's ±30 s window are appended through the same log parser, so probe and cause arrive in one result.

Shared guards 

SourcePathGuard (used by 1–3)
Every file access resolves through realpath and must stay inside the project root — one containment check defeats ../ traversal and symlink escapes alike. Denied outright: dot segments (.env, .git, .ddev), var/* except var/log, config/system/* and any settings.php/additional.php, key-material extensions (key/pem/crt/p12/pfx) and paths mentioning credential. Credential-looking assignment lines are value-redacted on read; the code walk skips vendor/, node_modules/, var/ and dot directories and only considers source extensions.
LogExceptionReader (used by 1 and 4)
Parses TYPO3 FileWriter records (error levels only, newest first, bounded to each file's 2 MB tail) into timestamp, level, component, message, exception class and stack frames. One parser, two consumers — probe_url's 5xx↔log correlation reuses it instead of duplicating the format knowledge.
probe_url SSRF containment
Only http(s), and the target host[:port] must match a site base or base variant of this instance (SiteFinder); relative paths resolve against the first site. Redirects are reported, never followed — a 3xx cannot bounce the probe off-host. Transport errors surface sanitized (URL credential parameters masked).

Consequences 

  • The "analyse the error" loop closes: probe_url → correlated log entry → get_last_exception (full trace + code) → read_source / search_code for the fix site — without leaving the playground.
  • All four tools are requiresAdmin() = true and live behind the ADR-043 group cascade (new group code; probe_url joins system), so one central toggle silences the whole family.
  • settings.php and other credential carriers are structurally unreadable even for admins — the guard has no bypass parameter by design.
  • probe_url performs real frontend requests (cache warm-up, log entries, load); it is deliberately GET-only, single-request, 15 s-capped.

ADR-045: Schema and resolution tools 

Status

Accepted

Date

2026-07-09

Deciders

nr_llm maintainers

Context 

The content and introspection tools (ADR-042) let an agent read records, TypoScript and TSconfig. Debugging schema and templating questions ("what does this table relate to?", "what fields does this FlexForm have?", "which Fluid file actually wins?") still required guesswork.

Two shapes were considered for schema access:

  1. Send the whole TCA and let the model reason. Rejected: the resolved TCA is several megabytes — it neither fits an LLM context window nor is affordable per call.
  2. Navigate: an index plus per-item detail. Chosen. The model lists table names cheaply, then pulls the full definition only for the tables it cares about. Validation-style "find the error" tools remain a separate, later concern; these tools are retrieval, chunked to stay within budget.

The tools are inspired by konradmichalik/typo3-ai-mate (the Fluid resolve and TCA introspection ideas) and hn/typo3-mcp-server (GetTableSchema / GetFlexFormSchema) — both GPL-2.0-or-later, like this extension. They are independent re-implementations; no code is copied and neither extension is a dependency.

Decision 

Four read-only built-in tools:

get_full_tca (group structure)
The TCA index: names + titles of every accessible table, each pointing at get_table_schema. This is the "navigate, don't dump" answer — the whole TCA is never serialised at once. Optional filter / extension.
get_table_schema (group structure)
One table's readable schema: control highlights plus, per field, the type and — for relations — the foreign table and relation kind (group/select/inline/category). This relation view is the value over the raw get_tca.
get_flexform_schema (group structure)
A FlexForm field's data structure rendered as sheets → fields, via FlexFormTools. When several structures are selectable by a pointer, the keys are listed for a precise follow-up call with ds_pointer.
fluid_resolve (group configuration)
The candidate template/partial/layout file paths in override order with an exists flag and the winning path, to debug "wrong template wins" or "not found".

Access model 

get_full_tca, get_table_schema and get_flexform_schema reuse TableReadAccessService (ADR-042): the sensitive-table denylist holds for every user including admins; non-admins additionally pass tables_select and the TCA adminOnly flag. Credential-like columns are shown by name and type only. fluid_resolve exposes file paths (never contents), rejects path traversal, and requires a valid extension key.

Consequences 

  • The agent can traverse the schema (index → detail) within token budget and reason about relations and FlexForm structures it previously could not see.
  • fluid_resolve resolves an extension's own Resources/Private paths. TypoScript-configured override root paths (plugin.tx_*.view.*RootPaths) require a live rendering context and are not reflected — documented as a known limitation, not a bug.
  • Write access remains out of scope; hn/typo3-mcp-server's DataHandler-plus-workspace staging is the reference design if it is added later (see ADR-042).

ADR-046: History, URL and validation tools 

Status

Accepted

Date

2026-07-09

Deciders

nr_llm maintainers

Context 

The schema tools (ADR-045) let an agent retrieve structure and reason over it. Three recurring editor/integrator questions still had no tool: "who changed this record?", "which page serves this URL?", and "is this TCA/TypoScript actually broken?". The last two are deterministic questions — an LLM guessing at brace balance or showitem consistency over retrieved text is unreliable where an exact scanner exists.

For TCA validation the core TcaMigration was considered and rejected: it only emits messages while migrating the raw, pre-boot TCA; at runtime $GLOBALS['TCA'] is already migrated, so replaying it reports nothing. Structural checks over the live TCA are implemented directly instead.

For TypoScript the core include-tree scanner (IncludeTreeSyntaxScannerVisitor, with SysTemplateTreeBuilder/IncludeTreeTraverser) is reused — the same code path the backend's TypoScript module uses to mark broken syntax. These classes are @internal; the risk is accepted because the surface used is tiny, covered by functional tests, and verified identical on 13.4 and 14.

Decision 

Four read-only built-in tools:

get_record_history (group content)
One record's sys_history newest-first: timestamp, resolved backend username, action, and per modification the changed fields as old → new pairs. Answers "wer hat die Überschrift geändert".
resolve_url (group structure)
URL → page mapping via the real SiteMatcher/PageRouter — site, language, page uid/title/slug, route arguments. Routing only, no HTTP; the complement of probe_url (which fetches but does not explain).
validate_tca (group structure)
Structural checks over the live TCA: ctrl.label/ctrl.type must name defined columns, foreign_table must reference TCA tables, types/palettes showitem entries must reference defined columns and palettes, flex ds_pointerField is flagged on v14+ (removed there).
check_typoscript (group configuration)
Constants and setup include trees of a page's sys_template chain run through the core syntax scanner: invalid lines, unbalanced braces, @import matching no file — each with source and line number.

Access model 

get_record_history and validate_tca reuse TableReadAccessService (ADR-042): the sensitive-table denylist holds for every user including admins; non-admins additionally pass tables_select, and get_record_history also requires page-show on the record's page (the per-row gate of read_records, fail-closed for unresolvable records). History values of credential-like fields are withheld (the fact of the change stays visible). resolve_url needs no host allowlist — SiteMatcher only knows this instance's sites, so foreign hosts cannot match by construction; non-admins must hold page-show on the resolved page, with the same neutral denial as get_page_content. check_typoscript is admin-only like get_typoscript, and reports source + line number + error kind only — the offending line's content is never echoed, because a broken constants line may carry an API key.

Consequences 

  • The four remaining diagnostic use-cases named in the tool-expansion plan (record attribution, URL mapping, TCA and TypoScript validation) are covered by deterministic tools instead of model guesswork.
  • check_typoscript depends on @internal core classes; a core refactoring may require adaptation. The functional tests pin the observable behaviour, so a break surfaces in CI, not at runtime.
  • validate_tca intentionally implements a small rule set with exact semantics rather than replicating the Extension Scanner; new rules can be added as they prove useful.

ADR-047: FAL tools 

Status

Accepted

Date

2026-07-09

Deciders

nr_llm maintainers

Context 

The tool family covered records, schema, configuration and code — but not the File Abstraction Layer. The recurring questions "which storages exist?", "what is in this folder?", "find that PDF", "where is this image used?" and "why is this image broken?" had a single narrow answer (read_fal_asset_meta, one file by uid) and no navigation around it.

FAL output egresses to the external LLM provider, so the family needs one shared containment: storage scoping. A single allow-list in one place beats five copies of the same intersection logic.

Decision 

Five read-only built-in tools in the NEW group files — the first group added since the initial taxonomy (ADR-043); third-party extensions remain advised to use their extension key as group:

list_fal_storages
The effective storages with uid, name, driver and status flags. The server-side base path is never part of the output.
browse_fal_folder
One folder's subfolders (with file count) and files (size, MIME type), resolved exclusively through the storage API. Identifiers are storage-relative; entries are capped.
search_fal_files
Substring search over file name and default-language metadata title/alternative. The model-chosen query is LIKE-escaped (literal %/_), missing files are excluded, results are capped.
get_fal_references
sys_file_reference usages of one file as table:uid (field) — hidden references marked, deleted ones invisible. The output states explicitly that soft references (RTE links, plain URLs) are not tracked, so "no references" is never read as "safe to delete".
find_missing_files
sys_file rows with missing = 1 in the effective storages, with the total count always reported alongside the capped listing.

Access model 

All five share FalStorageGate: the configured storage allow-list (default [1]), intersected for non-admins with the storages reachable through their file mounts (BackendUserAuthentication::getFileStorages(), verified identical on 13.4 and 14). Fail-closed: no backend user or an empty intersection yields the neutral denial. A file or folder outside the gate is indistinguishable from a missing one — the same neutrality contract as read_fal_asset_meta. get_fal_references additionally drops rows whose referencing table fails TableReadAccessService for non-admins. browse_fal_folder enforces FOLDER-level file-mount boundaries on top: for non-admins it sets ResourceStorage::setEvaluatePermissions(), so the core mount checks apply to every folder read. The mounts themselves are attached by the core StoragePermissionsAspect only inside a backend request — in any other context (CLI, scheduler) non-admin folder access therefore fails CLOSED (denied) rather than mount-blind. Server paths never egress: listings use storage-relative identifiers, and list_fal_storages omits the base path by design.

Consequences 

  • The FAL gap in the tool taxonomy is closed with navigation (storages → folders → files), search, usage and integrity tools.
  • The new files group can be disabled centrally, per configuration and per run like every other group (ADR-043).
  • get_fal_references reads sys_file_reference only; soft-reference tracking (sys_refindex) is a possible later extension if "is this file really unused?" needs a stronger answer.

ADR-048: Diagnostics tools 

Status

Accepted

Date

2026-07-09

Deciders

nr_llm maintainers

Context 

The tool family answers content, schema, configuration, code and file questions — but "establish the system context first" still needed a human: which extensions and versions run here, which sites exist, did the nightly task fire, is this a composer-mode v14 on PostgreSQL, what does the deprecation log say, which middleware could intercept this request. Every diagnosis conversation starts with a subset of these.

Decision 

Six read-only, admin-only built-in tools — they enumerate the instance's configuration and attack surface, so none is offered to non-admins:

list_extensions (group system)
Active packages with key, version, composer name and title via PackageManager. Package paths never egress.
get_site_config (group configuration)
Site listing, or one site's configuration flattened to dotted key: value lines. Keys matching the credential pattern of TableReadAccessService redact their value — with camelCase normalization (apiKeyapi_Key) because site settings are commonly camelCase while the pattern is snake_case-segment based. Output is line- and value-capped, never raw YAML.
list_scheduler_tasks (group system)
Plain columns of tx_scheduler_task only. The serialized_task_object blob is never unserialized — feeding a DB-supplied object graph to unserialize() is an object-injection primitive, and the plain columns answer the diagnostic question. The column set differs between 13.4 (SQL-defined) and 14 (TCA-defined, adds tasktype), so available columns are introspected per instance. An absent table degrades to "Scheduler is not installed."
get_system_status (group system)
TYPO3/PHP/database versions, application context, composer mode, OS family, timezone — versions and flags only, no paths, no hostnames. The database version comes from Doctrine's Connection::getServerVersion() (dbal  4.4 on both 13.4 and 14).
list_deprecations (group system)
Tail of the newest var/log/typo3_deprecations_*.log: distinct messages deduplicated with a ×count suffix, absolute project paths rewritten to relative, width- and count-capped. A missing file or disabled channel degrades to a plain message.
list_middlewares (group system)
One PSR-15 stack in execution order via MiddlewareStackResolver@internal core API (same caveat as the include-tree classes used by check_typoscript, ADR-046); its return type differs across versions (array on 13.4, ArrayObject on 14) and is consumed as an iterable. Resolution failures collapse into one neutral message.

Considered and rejected 

list_event_listeners was part of the original idea list: the core ListenerProvider exposes no stable public enumeration API across 13.4 and 14, so a listener inventory would depend on container internals that change per version. Dropped until the core offers a supported way.

Consequences 

  • A model can establish the full system context (versions, extensions, sites, automation, upgrade debt, request pipeline) without a human relaying installer or reports-module screenshots.
  • All six are admin-only; the system/configuration group toggles (ADR-043) disable them centrally, per configuration and per run.
  • Two @internal core APIs are consumed (MiddlewareStackResolver here, the include-tree scanner in ADR-046) — accepted as the only practical access path, guarded by neutral failure modes and the CI matrix across both supported majors.

ADR-049: RAG site-search tools over installed search indexes 

Status

Accepted (the Solr language filter is corrected by ADR-067)

Date

2026-07-09

Amended

2026-07 by ADR-067

Authors

Netresearch DTT GmbH

Context 

Agent runs should be able to answer questions about the website's own content with cited evidence instead of model world-knowledge. The retrieval source already exists in most installations: a TYPO3 search index — EXT:solr, ke_search or the core's indexed_search. What is missing is a controlled retrieval layer that (a) uses whichever index is installed, (b) degrades gracefully when none is, and (c) hands the model a curated evidence package with resolvable sources rather than raw search hits.

A generic per-engine tool list (solr_search, ke_search_query, …) was rejected: the model would need to know what is installed, every engine would leak its own result shape into prompts, and the tool count would grow per engine. Embedding/vector retrieval is deliberately out of scope for this iteration — keyword retrieval over the existing index is measured first; a vector store would add tables, chunking and reindex pipelines whose benefit is unproven for the target sites.

Decision 

One retrieval core, many backends. A new Service/Retrieval layer defines SearchBackendInterface (isAvailable(), getPriority(), search(RetrievalQuery, AccessContext)) with four implementations, collected via the nr_llm.retrieval_backend tag:

  1. SolrSearchBackend — talks to the Solr server EXT:solr provisioned over the HTTP select API instead of EXT:solr's @internal PHP classes (for TYPO3 14 only a beta of EXT:solr exists): endpoints come from the documented site-configuration solr_*_read keys with per-language overrides, every query carries the {!typo3access}0,-1 public filter, and no composer dependency on EXT:solr exists.
  2. KeSearchBackend — reads tx_kesearch_index directly: MATCH … AGAINST on MySQL/MariaDB, LIKE elsewhere. Matches title/content only — never hidden_content, which ke_search itself never renders.
  3. IndexedSearchBackend — reads the index_* tables directly (word-hash join with the md5 computed in PHP; LIKE over index_fulltext when useMysqlFulltext left the word tables empty).
  4. DatabaseSearchBackend — always-available fallback: LIKE across pages/tt_content search fields, grouped per page.

RetrievalService asks the backends in priority order and uses the first available one — no cross-engine score merging, because Solr relevance, MySQL fulltext scores and LIKE hits are not comparable; re-ranking is a future embedding concern. The answering backend is named in the result so the model knows the evidence quality.

Two tools, one new group rag: site_rag_query (question → evidence package: source_id · title · url plus a match excerpt per source) and site_fetch_source (source_id → the indexed full text, capped). Tool arguments are model-chosen and untrusted: length caps, source-id grammar validation and result caps apply.

Access model, fail-closed. Index-level filtering is always public-only (fe_group ''/0, gr_list 0,-1, Solr access filter {!typo3access}0,-1): RAG evidence is what the anonymous website visitor could read. Because that content is by definition readable by every backend user, no per-user page narrowing applies (unlike search_records, which exposes non-public backend records); the tools stay fail-closed without a backend user like every builtin. AccessContext (backend user / frontend groups / public) travels through the retrieval core so a later frontend endpoint can widen filtering per fe_group without touching the backends' call sites — it is not consumed beyond public-only in this iteration.

Web search stays an interface. WebSearchBackendInterface (site-limited external search) is defined but has no implementation; no network egress ships with this decision.

Consequences 

  • Installations get grounded site answers with whatever index they already run; a bare instance still works through the database fallback, visibly labelled as such in the evidence header.
  • Direct table access to tx_kesearch_index and index_* trades API stability for decoupling: both schemas are verified against the currently supported versions (ke_search v6.6/v7, core 13.4/14.x — identical), but future majors can drift; isAvailable() checks table presence, and functional tests pin the expected schema.
  • The Solr adapter depends on the documented site-configuration keys and on the typo3access query parser from EXT:solr's configsets, not on EXT:solr PHP internals; any HTTP or configuration failure is treated as "backend unavailable" and the cascade continues.
  • Stale indexes cite stale content (ke_search incremental runs never delete; indexed_search updates on render) — a known property of search-index RAG, documented for editors.
  • A future vector/hybrid retriever or web-search implementation slots in as another backend behind the same interface and cascade.

ADR-051: Tool-calling feature service — narrow consumer interface 

Status

Accepted

Date

2026-07-12

Authors

Netresearch DTT GmbH

Context 

Every capability except tool calling has a narrow feature-service interface (CompletionServiceInterface, EmbeddingServiceInterface, VisionServiceInterface, TranslationServiceInterface) whose docblocks tell consumers to depend on the interface, not the concrete class. Tool calling is the exception: chatWithTools() and chatWithToolsForConfiguration() exist only on LlmServiceManagerInterface — a nineteen-method surface.

A consumer that needs exactly tool calling therefore binds to the whole manager interface. That coupling is not theoretical: nr_ai_search needs a single method, keeps a hand-written fake of the manager interface for its unit tests, and that fake fatals every time the manager interface grows a method — its 0.13 → 0.16 update was blocked in CI by the two methods 0.14 added, none of which it calls.

Decision 

Add the missing feature-service pair, mirroring the existing pattern:

  • Netresearch\NrLlm\Service\Feature\ToolCallingServiceInterface with exactly the two tool-calling entry points, chatWithTools() and chatWithToolsForConfiguration(), signature-identical to the manager's.
  • Netresearch\NrLlm\Service\Feature\ToolCallingService delegating to LlmServiceManagerInterface, adding only the feature-service standard beUserUid auto-population (AutoPopulatesBeUserUidTrait, REC #4) so per-user budget enforcement works without caller wiring.
  • Registered in Configuration/Services.yaml like the other feature services (public service + public interface alias).

The manager keeps its methods unchanged — this is additive; the feature service is the documented consumer entry point going forward.

Consequences 

  • A tool-calling consumer's test double is two methods, and additions to LlmServiceManagerInterface no longer break consumers that do not call them.
  • The feature-service catalogue now covers every capability, so the integration guide's "depend on the feature interface" rule holds without exception.
  • Consumers pinning a provider or configuration keep doing so through ToolOptions / the LlmConfiguration parameter — the service adds no second way to select one.

ADR-052: Usage attribution honours the caller-supplied beUserUid 

Status

Accepted

Date

2026-07-12

Authors

Netresearch DTT GmbH

Context 

Every option object carries withBeUserUid() (BudgetAwareOptionsInterface), and the manager forwards that uid as pipeline metadata (BudgetMiddleware::METADATA_BE_USER_UID), where BudgetMiddleware uses it for per-user budget enforcement. Usage attribution, however, ignored it: UsageTrackerService always read the ambient backend.user context aspect to fill the be_user column.

For backend-module calls the two sources agree. For every caller outside a backend-user request — frontend plugins, Messenger/CLI workers, scheduler tasks — they do not: the aspect resolves to 0, so usage lands in the anonymous bucket even when the caller passed an explicit uid. Downstream extensions worked around this by impersonating a technical backend user for the duration of a call — swapping the backend.user aspect (and restoring it in a finally) purely so the usage row gets the right be_user. nr_ai_search's BackendUserContext::runAs() is such a workaround, wrapped around every RAG chat call. Enforcement and attribution also disagreed with each other: the budget gate charged the option-supplied user while the usage row credited the ambient one.

Decision 

The caller-supplied uid wins; the ambient aspect stays the fallback.

  • UsageTrackerServiceInterface::trackUsage() gains an optional trailing ?int $beUserUid = null parameter. null preserves the previous behaviour (ambient backend.user aspect, 0 when unauthenticated).
  • UsageMiddleware reads BudgetMiddleware::METADATA_BE_USER_UID from the pipeline context — the same key the budget gate reads — and passes it through, so enforcement and attribution can no longer disagree.

Consequences 

  • A consumer that already sets withBeUserUid() gets correct attribution in frontend/CLI contexts with no further wiring; the aspect-swap workaround becomes unnecessary for usage tracking.
  • Backend-module calls are unaffected: they set no option uid, and the ambient fallback resolves the same user as before.
  • UsageTrackerServiceInterface implementers must add the new parameter (semver-minor breaking in the 0.x line, same policy as ToolInterface::getGroup() in 0.15.0). In-repo, UsageTrackerService is the only implementation.
  • The specialized translator path forwards the uid even though it bypasses the middleware pipeline: TranslationService re-attaches the resolved uid to the options array it hands to TranslatorInterface implementations (the beUserUid key — budget fields are deliberately excluded from TranslationOptions::toArray()), and DeepLTranslator / LlmTranslator pass it on to trackUsage(). The key is attribution metadata only; translators never send it to the remote API.
  • LlmTranslator additionally threads the uid into the ChatOptions of its underlying chat calls (translation and language detection), so the pipeline-recorded chat row — which carries the tokens and cost — lands under the same be_user as the translation-level row, and BudgetMiddleware enforces the caller's budget on those calls. A direct TranslatorInterface::detectLanguage() call has no options parameter and stays ambient.
  • The speech and image services were initially deferred and are now covered by ADR-057: TranscriptionOptions, SpeechSynthesisOptions and ImageGenerationOptions implement BudgetAwareOptionsInterface, FalImageService reads the documented beUserUid array key, and all four services forward the uid to their trackUsage() calls. Attribution only — those services bypass the middleware pipeline, so no budget enforcement happens there.

ADR-053: One marker interface for all thrown exceptions 

Status

Accepted

Date

2026-07-12

Authors

Netresearch DTT GmbH

Context 

A consumer that wraps an nr_llm call and wants to convert failures into its own domain exception has to enumerate concrete classes today:

} catch (
    InvalidArgumentException          // PHP's own, from ChatMessage/ToolSpec::fromArray()
    | NrLlmInvalidArgumentException   // options validation
    | ProviderException               // covers the 5 provider subtypes
    | BudgetExceededException
    | AccessDeniedException
    | ConfigurationNotFoundException $e
) {
Copied!

Two problems. The list goes stale silently: when a future version adds or rethrows a new exception type, existing catch lists let it escape as an uncaught 500 instead of the consumer's clean error path. And the chat/tool value objects' fromArray() normalisation threw PHP's global \InvalidArgumentException, a different class from nr_llm's own Exception\InvalidArgumentException — the first entry in the list above exists only because of that mismatch (nr_ai_search's NrLlmChatClient documents exactly this trap).

Decision 

  • Netresearch\NrLlm\Exception\NrLlmExceptionInterface (extending \Throwable) marks every exception this extension throws on its public API surface. The five core exceptions and ProviderException (which its five subtypes inherit from) implement it.
  • ChatMessage / ToolSpec / ToolCall normalisation errors now throw Exception\InvalidArgumentException instead of PHP's global class. Backwards compatible: the nr_llm class extends \InvalidArgumentException, so existing catches keep matching.
  • A reflection test sweeps both exception directories so a future exception class cannot ship without the marker.

Consumers can now write catch (NrLlmExceptionInterface $e) — one arm, future-proof.

Consequences 

  • The remaining classes that imported the global InvalidArgumentException for their own validation errors (response parsers, task readers, backend response DTOs, value objects) throw Exception\InvalidArgumentException now — the compatible follow-up named here is done, guarded by the same reflection test. One deliberate exception: Service\Task\TaskInputResolver keeps the global import because it only catches the exception around RecordTableReader::fetchAll() — narrowing that catch to the nr_llm subclass would miss a plain \InvalidArgumentException raised by third-party code inside the read path.
  • Catch-all remains opt-in: consumers that want to handle budget exhaustion differently from provider outages keep catching the concrete classes.

ADR-054: Typed tool turns on ChatMessage instead of wire arrays 

Status

Accepted

Date

2026-07-13

Authors

Netresearch DTT GmbH

Context 

ChatMessage modelled only (role, content). Two message shapes that every tool-calling conversation needs could not be expressed as value objects:

  1. the assistant turn that carries the model's tool_calls, and
  2. the tool turn that answers one call via its tool_call_id.

So every tool loop hand-built raw OpenAI-wire arrays: ToolLoopService assembled both turns as associative arrays (with the arguments JSON-encoding and the empty-{}-not-[] subtlety inlined at the call site), and consuming extensions copied the pattern. The developer documentation taught the raw-array shape and had drifted further: its example still treated CompletionResponse::$toolCalls elements as nested arrays ($toolCall['function']['name']) although they have been typed ToolCall value objects since that migration.

Untyped wire arrays at the public seam mean no validation (a tool message without a tool_call_id fails only at the provider, with a provider-specific 400), duplicated serialisation subtleties, and drift between code and documentation.

Decision 

ChatMessage gains two optional, validated tail fields and two named constructors — it stays the single value object for all four roles rather than growing per-shape subclasses:

  • ?array $toolCalls (list<ToolCall>), allowed only on the assistant role; every element must be a ToolCall; an empty list is rejected (providers 400 on tool_calls: []).
  • ?string $toolCallId, allowed only on the tool role and must be non-empty.
  • ChatMessage::assistantToolCalls(array $toolCalls, ?string $content = null)null content (what providers send alongside tool calls) is stored as '' because the $content property stays a non-nullable string.
  • ChatMessage::toolResult(string $toolCallId, string $content).

Wire shape. toArray() (and jsonSerialize()) emits the OpenAI-compatible request form: tool_calls entries carry function.arguments as a JSON-encoded string, with empty arguments encoding to {} (an object), never []; tool_call_id is emitted when set. This deliberately differs from ToolCall::toArray(), which keeps the legacy decoded-map form for CompletionResponse consumers — ToolCall::fromArray() accepts both variants, so ChatMessage::fromArray() round-trips either shape (and accepts content: null alongside tool_calls).

Transport path. Every provider adapter flattens messages via $m instanceof ChatMessage ? $m->toArray() : $m before building its payload, and LlmServiceManager::normaliseMessages() passes ChatMessage instances through untouched — no layer rebuilds messages from role + content alone, so the new fields reach the HTTP payload intact. ClaudeProvider / GeminiProvider / Ollama already convert from that OpenAI wire shape into their native formats. A unit test pins the path end-to-end at the mocked HTTP boundary.

ToolLoopService builds both turns through the new factories; its private raw-array assembly is deleted.

Consequences 

  • Tool loops — in this extension and in consumers — compose typed, validated turns; invalid shapes (a tool result without an id, tool calls on a user message) fail fast with nr_llm's InvalidArgumentException instead of a provider 400.
  • The arguments-encoding and {}-vs-[] subtleties live in exactly one place, ChatMessage::toArray().
  • Raw-array messages remain accepted everywhere for back-compat: the manager's normalisation still passes richer arrays through unchanged, and fromArray() now understands the two tool-turn keys.
  • ChatMessage::toArray()'s return shape gains two optional keys; callers that assumed exactly {role, content} for tool-loop messages must use the documented shape. Plain messages serialise byte-identically to before.
  • The developer documentation example is rewritten on top of the value objects, closing the $toolCall['function']['name'] drift.

ADR-055: Embeddings join the configuration path; dimensions metadata 

Status

Accepted

Date

2026-07-13

Authors

Netresearch DTT GmbH

Context 

The three-tier model (Provider → Model → Configuration, ADR-001) reaches every chat-shaped capability: completeWithConfiguration(), chatWithConfiguration(), streamChatWithConfiguration() and chatWithToolsForConfiguration() all resolve the adapter from a DB-backed LlmConfiguration (vault key + model + pricing) and run through the middleware pipeline, so budgets are enforced and cost is attributed per configuration.

Embeddings did not. LlmServiceManager::embed() only accepted EmbeddingOptions with raw provider/model strings, resolved against ExtensionConfiguration and a model-less transient configuration. An embedding consumer that persists vectors (a search index, semantic auto-linking — see the scope boundary in ADR-050) therefore had to duplicate provider, model and dimensionality into its own extension configuration, bypassing per-configuration budgets and cost attribution entirely.

The dimensionality gap made this worse: no record anywhere stated how many dimensions a model's vectors have. A consumer validating a persisted vector index against the configured model had to run a live "calibration probe" — embed a throwaway string and count the floats — which costs a provider call and fails when the provider is unreachable.

Decision 

Embeddings join the configuration path. LlmServiceManager::embedForConfiguration() mirrors chatWithToolsForConfiguration(): it resolves the adapter via getAdapterFromConfiguration(), runs through the middleware pipeline with ProviderOperation::Embedding and the budget metadata from the options, and guards the embeddings feature the same way embed() does (UnsupportedFeatureException when the provider lacks it). Per-call EmbeddingOptions take precedence over the configuration's stored defaults — an options model overrides the configuration's model id. Caching mirrors embed(): a positive cache_ttl places a cache key on the call context (keyed on the configuration identifier plus the effective model), so two configurations pointing at different models never share cache entries.

The high-level feature service follows: EmbeddingService::embedForConfiguration() and embedBatchForConfiguration() delegate to the manager and populate beUserUid via the shared auto-populate wiring, exactly like the existing embed()/embedBatch() paths.

Model records carry dimensions metadata. tx_nrllm_model gains a dimensions column (integer, 0 = unknown, declared like context_length), surfaced in the TCA next to the other model limits and on the Model entity as getDimensions()/setDimensions(). It is descriptive metadata: nothing in nr_llm enforces it at call time.

Consequences 

  • Embedding consumers select a backend-managed configuration instead of duplicating provider + model + dimensions into their own extension configuration; per-configuration budgets and cost attribution apply to embeddings like to every chat-shaped capability.
  • A consumer can validate a persisted vector index against the configured model by comparing its stored dimensionality with getLlmModel()->getDimensions() — no live calibration probe, no provider round-trip. A value of 0 means "unknown"; consumers fall back to their previous behaviour then.
  • LlmServiceManagerInterface and EmbeddingServiceInterface gained methods — implementers outside this repo must add them.
  • nr_llm's embedding capability remains stateless (ADR-050): the configuration path changes how the call is resolved and accounted, not what is persisted. Vector stores stay out of scope.

ADR-056: Configuration presets — consumer-declared, admin-imported records 

Status

Accepted

Date

2026-07-13

Authors

Netresearch DTT GmbH

Context 

Extensions consuming nr_llm (e.g. nr_ai_search) need specific LlmConfiguration records to exist — a chat configuration with tool support, an embedding configuration, and so on. Today every consuming extension documents these records in prose and each admin re-creates them by hand, guessing at capabilities and parameters. That is error-prone (typos in identifiers break the consumer's lookup) and opaque (the admin cannot see what an installed extension still needs).

At the same time, a consuming extension must never dictate the supply side: which provider, which model, or which API key satisfies its needs is strictly the admin's decision — nr_llm's three-tier architecture (ADR-001) and vault-only key storage (ADR-012) forbid anything else. The extension only knows its requirements: "I need a model that can chat and call tools, with at least 8k context."

nr_llm already has both halves of the machinery: DI-tag discovery with a tagged-iterator registry (nr_llm.toolToolInterface / ToolRegistry, ADR-038) and criteria-mode configurations resolved at runtime by ModelSelectionService.

Decision 

Consuming extensions declare the configurations they need as presets via a DI tag; nr_llm lists undeclared-but-not-yet-imported presets as pending; a backend admin imports one with a single confirmation.

  1. Declaration via DI tag. A consumer implements ConfigurationPresetProviderInterface (tag nr_llm.configuration_preset, auto-applied by AutoconfigureTag, mirroring ToolInterface) and returns ConfigurationPreset value objects. ConfigurationPresetRegistry collects them through a tagged iterator and fails fast on duplicate identifiers.
  2. Presets express requirements, never supply. A preset carries a namespaced identifier (nr_ai_search.chat), name, description, ModelSelectionCriteria (at least one capability is mandatory), and optional seeds (system prompt, temperature, max tokens, daily budgets, allowed tool groups). It can never name a provider, a model, or an API key — the type system simply offers no field for them.
  3. Imported records are criteria-mode configurations. Import creates the record with model_selection_mode = criteria, so ModelSelectionService resolves it on every run against whatever providers and models the admin has configured. The admin keeps full control: the record is a normal tx_nrllm_configuration row, editable and deletable like any other.
  4. Checksum idempotency. The preset's SHA-256 checksum over a canonical JSON encoding of all declared fields is stored in the new preset_checksum column (type passthrough, no form field). "Pending" is defined by identifier absence, so an imported record is never re-offered or overwritten; the stored checksum makes a changed declaration in the consumer detectable for a future "update available" surface.
  5. Preflight before import. ConfigurationPresetImportService checks the criteria through the very ModelSelectionService that later resolves the record, and reports either the model the criteria currently match or the first requirement that eliminates every candidate. Import refuses duplicates and unsatisfiable presets.
  6. Endpoints-first v1. The admin surface are two admin-gated AJAX endpoints (nrllm_preset_list, nrllm_preset_import; guard per ADR-037). A backend-module UI on top of them is a follow-up, not part of this slice.

Consequences 

Positive 

  • A consuming extension's needs become machine-readable and visible; the admin imports a correct record with one confirmation instead of hand-copying identifiers and criteria out of a README.
  • The supply/demand boundary is enforced by construction: presets cannot carry providers, models, or keys.
  • Imports cannot silently produce dead configurations — the preflight answer comes from the same code path the runtime uses.
  • Re-imports are impossible by design (identifier presence), and changed declarations are detectable (checksum).

Negative 

  • One more DI-tag discovery surface to maintain.
  • v1 has no backend-module UI; admins need the AJAX endpoints (or the follow-up module) to see and import pending presets. (Implemented since — see the note above.)
  • The stored checksum only detects declaration drift. (Superseded: the diff + re-confirm update flow is now implemented — see the amendment below.)

Alternatives considered 

Auto-create records at extension install time. Rejected: it bypasses the admin's confirmation, creates records that may be unsatisfiable (no matching model yet), and silently mutates the database on `composer require`.

Declaration via YAML/PHP config files instead of a DI tag. Rejected: the DI tag reuses the established, tested discovery mechanism (nr_llm.tool), is auto-wired with zero per-extension configuration, and gives compile-time class references instead of stringly-typed files.

Fixed-mode presets naming a concrete model. Rejected outright: it would invert the three-tier ownership (ADR-001) and break on every instance whose admin chose a different provider.

Amendment (2026-07-14): checksum-driven update flow 

The update flow the original decision deferred is now implemented as a diff + re-confirm step on top of the existing drift detection, in the same scope (no schema change, no new ADR).

Diff is declared-versus-current. The as-imported declaration is not reconstructable (only the checksum is stored), so the diff compares the current declaration against the record's current values. That is exactly the right basis for the admin's decision: it shows the record values an update would overwrite. ConfigurationPresetDiffService produces a PresetDiff of per-field deltas; ConfigurationPreset::toCanonicalArray() is the single field list both the checksum and the diff read, so the two can never disagree.

What an update applies. Name, description and the model-selection criteria always follow the declaration; the optional seeds (system prompt, temperature, max tokens, the three daily budgets, allowed tool groups) are applied only when the declaration carries a value. A seed the declaration left null never resets the record — seeds are initial values, so the diff shows only fields the declaration has a value for that differ. After applying, the record's stored checksum is re-stamped to the current declaration, which clears the drift hint (an update always resolves the drift, even when the diff was empty because only a seed was removed).

What an update never touches. is_active, is_default, be_groups and the fallback chain are the admin's — the record stays a normal row (per point 3 above). If the admin switched the record to fixed model selection, the update is refused (a typed 422): applying the declared criteria would override the admin's supply-side model choice. Updating also runs the same preflight as import, so a changed, currently unsatisfiable criteria set is refused with the missing requirement named.

Surface. Two more admin-gated AJAX endpoints (nrllm_preset_diff GET, nrllm_preset_update POST; guard per ADR-037). The Configurations module renders a "Review update" action next to the drift hint that opens the diff in a re-confirm modal; nrllm_preset_list's drifted entries additively gained a changedFields summary (existing keys unchanged).

ADR-057: Speech and image services carry attribution in options 

Status

Accepted

Date

2026-07-14

Authors

Netresearch DTT GmbH

Context 

ADR-052 made the caller-supplied beUserUid win over the ambient backend.user aspect for usage attribution, but deferred the four specialized speech/image services: their option shapes (TranscriptionOptions, SpeechSynthesisOptions, ImageGenerationOptions, and FalImageService's plain array) carried no budget fields, so every transcription, synthesis and image generation landed in the ambient bucket — be_user = 0 for frontend, CLI and worker callers — with no way for a consumer to attribute the spend.

Unlike chat/completion/embedding/vision, these services do not run through the middleware pipeline (they dispatch HTTP directly via AbstractSpecializedService), so neither BudgetMiddleware nor UsageMiddleware can supply the uid; the services call trackUsage() themselves.

Decision 

Extend the options-based attribution of ADR-052 to the specialized services — attribution only, no enforcement:

  • TranscriptionOptions, SpeechSynthesisOptions and ImageGenerationOptions implement BudgetAwareOptionsInterface via BudgetFieldsTrait: optional trailing beUserUid / plannedCost constructor parameters, fromArray() keys of the same names, validation rejecting negative values. The fields stay out of toArray() — the services build their wire payload from toArray(), so a missed exclusion would leak the uid to the remote API.
  • WhisperTranscriptionService, TextToSpeechService and DallEImageService forward getBeUserUid() to their trackUsage() calls.
  • FalImageService keeps its plain options array (no DTO exists) and reads a documented beUserUid key — the same array pattern TranslationService uses for translators. The payload builder is an explicit allowlist, so the key never reaches the FAL API.
  • DallEImageService::createVariations() and edit() take no options object; they gain an optional trailing ?int $beUserUid scalar parameter instead of growing a new options type for two DALL-E-2-only endpoints.

Consequences 

  • Consumers can attribute speech and image spend per backend user from any context; without the uid the ambient fallback keeps the previous behaviour.
  • Attribution and enforcement remain decoupled: the specialized services still bypass the middleware pipeline, so per-user budget ceilings are NOT enforced on speech/image calls. plannedCost is carried but unused there. Routing these services through a budget pre-flight is a separate decision with its own trade-offs (no token-based cost model for FAL, multipart request flows) and gets its own ADR if a consumer needs it.
  • The three option DTOs and the two DALL-E signatures are public surface; the additions are optional trailing parameters (semver-minor in the 0.x line, same policy as the ADR-052 trackUsage() change).
  • BudgetAwareOptionsInterface's docblock now names the attribution-only consumers so the "reaches BudgetMiddleware" assumption is not silently wrong.

ADR-058: Telemetry Middleware 

Status

Accepted

Date

2026-07

Amended

2026-08-11 by ADR-153

Authors

Netresearch DTT GmbH

Context 

The middleware pipeline (ADR-026: Provider Middleware Pipeline) records cost and tokens through UsageMiddleware, but only for calls that succeed. Its own doc block names the gap:

The middleware never runs when $next throws: failed calls are not tracked here. If failure-rate telemetry is needed later, a dedicated middleware can wrap and record regardless of outcome.

So today there is no durable record of:

  • how often a provider call fails, and with which exception type;
  • how long a call takes (latency), including the cache lookup;
  • whether a response was served from cache;
  • how many fallback configurations had to be tried before one answered.

UsageMiddleware writes to tx_nrllm_service_usage, which is a daily aggregate keyed by service/provider/model/user — it cannot answer "which correlation id failed at 14:03 and why". Correlation ids exist on the ProviderCallContext and are already logged by FallbackMiddleware, but logs are not queryable telemetry.

Decision 

Add a dedicated TelemetryMiddleware and a per-request log table.

1. Table tx_nrllm_telemetry — one immutable row per pipeline run (not an aggregate). Columns: correlation_id, operation, provider, model, configuration_identifier, be_user, success, error_class, latency_ms, cache_hit, fallback_attempts, crdate. No TCA / backend UI — it is a log read via SQL / analytics, like the other UI-less tables (tx_nrllm_service_usage, tx_nrllm_tool_state).

2. TelemetryMiddleware at priority 110 — the outermost layer, outside Cache. It measures wall-clock latency with hrtime() around $next, catches any Throwable, writes exactly one row on both success and failure, and re-throws the exception unchanged. Latency therefore includes the cache lookup, and a cache-served response still produces a row.

3. Cache-hit signal. The pipeline threads one immutable ProviderCallContext through every layer and the $next callable only forwards the LlmConfiguration — never a context. An inner middleware thus cannot hand a modified context back to an outer one. The one channel that survives the unwind is a mutable object reachable from the shared context: TelemetrySignals. The context default-constructs one per call. CacheMiddleware calls recordCacheHit() on a hit; the outer TelemetryMiddleware reads it.

4. Fallback count. FallbackMiddleware calls recordFallbackAttempt() on TelemetrySignals once per fallback configuration it actually dispatches (the primary attempt is not counted); the middleware reads it (0 when it was never set).

5. Attribution. be_user is the caller-supplied BudgetMiddleware::METADATA_BE_USER_UID when present, else the ambient backend.user context aspect, else 0 — the same resolution UsageTrackerService uses.

6. Persistence goes through a narrow TelemetryRepository (Doctrine ConnectionPool directly, no Extbase repository), mirroring how UsageTrackerService writes. The service is private (ADR-028); nothing resolves it by class name from the container.

7. Purge command nrllm:telemetry:purge (--days, default 30) deletes rows older than the retention window. Registered via the native #[AsCommand] attribute (autoconfigured), like TYPO3 core commands.

8. Deactivation. The extension setting telemetry.enabled (default on — observability by default) turns the middleware into a verbatim pass-through when disabled.

Consequences 

  • One row per request = growth. Unlike the usage aggregate, the log grows with traffic. The nrllm:telemetry:purge command bounds it; run it from the scheduler.
  • error_class, not message — a deliberate privacy trade-off. Only the exception FQCN is stored. Exception messages can carry payload fragments (a prompt substring, a URL with a token), so they are never persisted here. No prompts and no responses are stored either. The central privacy model (retention tiers none/metadata/redacted/full) is a later workstream; this middleware is metadata-only by construction.
  • Latency includes the cache lookup. Because Telemetry sits outside Cache, latency_ms measures the whole pipeline as the caller experiences it, cache hits included. That is the intended semantic (end-to-end latency), not provider-only time.
  • provider / model reflect the requested primary configuration. Telemetry sits outside FallbackMiddleware, so it records the configuration the caller asked for. A fallback swap shows up as fallback_attempts > 0; the provider/model/cost of the configuration that actually served live in the usage table (UsageMiddleware sees the served config). Ad-hoc direct calls carry no attached model, so provider/model are empty and the provider is encoded in the ad-hoc:<operation>:<provider> identifier.

  • Fail-soft. A telemetry write error is logged and swallowed; it never breaks the call it observes.
  • Streaming produces no telemetry row. Streaming deliberately stays out of the pipeline (ADR-026: Provider Middleware Pipeline) — once the first chunk is emitted a provider cannot be swapped mid-stream. A streaming lifecycle (with its own telemetry) is a separate workstream.
  • Mutable state on an "immutable" context. TelemetrySignals is the one mutable object the otherwise-immutable ProviderCallContext carries. It holds only cross-cutting observability state, never payload, so it does not weaken ADR-026's "payload stays in the terminal closure" rule. A dedicated typed property (rather than a magic metadata key seeded by every caller) means every pipeline run — present and future entry points — captures cache/fallback signals with no per-caller wiring.

Alternatives considered 

  • Extend UsageMiddleware to also record failures. Rejected: usage is an aggregate keyed for cost roll-ups; per-request failure/latency rows have a different shape, lifetime (purged) and index set. Overloading one table would break both.
  • Log-only (PSR-3), no table. Rejected: logs are not queryable telemetry. Failure-rate and latency questions need a table an analytics view can group.
  • Seed a mutable signal bag into the metadata map at each pipeline entry point. Rejected: it pushes telemetry wiring into every caller and silently loses cache/fallback signals for any future entry point that forgets to seed. A default-constructed context property covers all runs by construction.
  • Record provider/model from the response. Rejected for the outer layer: there is no response on the failure path, and duplicating UsageMiddleware's response-shape extraction at the outermost layer couples telemetry to every response type. Sourcing from the requested configuration is deterministic on success and failure.

References 

  • ADR-026: Provider Middleware Pipeline — Provider Middleware Pipeline (the pipeline and ordering this extends; UsageMiddleware's open failure-telemetry note).
  • ADR-025 — Per-User AI Budgets (be_user attribution key reused here).
  • ADR-028 — Public Services Policy (the recorder stays private).

ADR-059: Decompose LlmServiceManager into focused collaborators 

Status

Accepted

Date

2026-07-14

Authors

Netresearch DTT GmbH

Context 

LlmServiceManager had grown to 987 lines. It is the extension's central entry point — a SingletonInterface implementing LlmServiceManagerInterface, which ADR-028 classifies as a Category-1 public API. A responsibility scan found nine distinct groups in one class:

  1. Skill-injection glue (delegates to SkillInjectionService).
  2. Default-configuration resolution.
  3. Loading the nr_llm extension configuration.
  4. The keyed, ExtensionConfiguration-backed provider registry (register / look up / list / configure providers by string identifier).
  5. Generic dispatch (chat, complete, embed, vision, streamChat, chatWithTools).
  6. Configuration-backed dispatch (the *WithConfiguration methods).
  7. The database-backed adapter factory facade.
  8. Pipeline plumbing (runThroughPipeline, budget metadata, transient configuration synthesis).
  9. Message shaping (system-prompt injection and message normalisation).

Two of these groups additionally carried duplicated code: the two embedding entry points each held an inline copy of the cache-metadata block, and six generic entry points repeated the same options / provider-key extraction preamble.

Decision 

Decompose the manager in stages. This ADR records stage 1, which extracts the self-contained groups while leaving the dispatch logic in place. The manager remains final and keeps implementing the unchanged LlmServiceManagerInterface; every former public method is retained as a thin delegation, so the Category-1 contract, the class name, registerProvider and the three non-interface public methods (getAdapterFromModel, getAdapterFromConfiguration, getAdapterRegistry) are unchanged.

Extracted collaborators (all private, autowired via the Classes/* resource block in Services.yaml):

  • KeyedProviderRegistry — groups 3 + 4. Holds the mutable provider map and the loaded extension configuration, so it is itself a SingletonInterface. ProviderCompilerPass still adds its registerProvider method calls to the manager service, which forward here.
  • ConfigurationResolver — group 2. readonly; resolves the backend-managed default configuration through the repository.
  • MessageShaper — group 9. readonly, stateless message normalisation and system-prompt injection.
  • EmbedCacheKeyBuilder — deduplicates the two inline embed cache-metadata blocks. The blocks are not identical: the ad-hoc embed() path keys by provider identifier with the payload {input, options} and a nrllm_provider_<id> tag, while embedForConfiguration() keys by configuration identifier plus the effective model (options override or the configuration's model id) with a nrllm_configuration_<id> tag, so two configurations pointing at different models never share entries. The builder therefore shares only the structure (the positive-ttl guard and the {cacheKey, cacheTtl, cacheTags} shape with the common nrllm_embeddings tag); each caller supplies its own namespace, key payload and scope tag. This difference is intentional, not a defect.

The repeated options / provider-key preamble becomes a private splitProviderKey() on the manager (the six callers all remain on the manager in stage 1).

The manager's constructor changes accordingly — it drops ExtensionConfiguration, LoggerInterface, CacheManagerInterface and the LlmConfigurationRepository (now owned by the collaborators) and gains the four collaborators. The constructor is not part of the interface contract; production wiring is autowired.

Consequences 

  • Behaviour is unchanged. The interface signatures, the provider-resolution error messages and their exception codes, and the two embed cache-keying strategies are all preserved. The existing manager / integration / e2e tests continue to exercise the behaviour through the facade; they construct the manager through a test factory (LlmServiceManagerTestFactory) that accepts the previous leaf-dependency shape and wires the new collaborators, so the call sites did not each have to change shape. The extracted classes additionally get their own unit tests.
  • New private services keep the documented public-service set stable (ADR-028); the PublicServicesPolicyTest count is unchanged.
  • The manager drops from 987 to roughly 790 lines.

Stage 2 (not in this change) 

Groups 5 and 6 (generic and configuration-backed dispatch) plus the pipeline plumbing (group 8) remain on the manager. Extracting per-operation dispatchers (completion, embedding, streaming, tool-calling) is deferred to a follow-up so this change stays reviewable and behaviour-preserving. It remains worthwhile: after stage 1 the manager is still dominated by the dispatch methods, and those are the natural seam for the privacy-model entry point work (ADR-026 payload constraint). Stage 2 should be taken up when that work needs the seam.

ADR-060: Quality evaluation — golden sets, grading and regression detection 

Status

Accepted (quality is no longer only a separate hook — see ADR-142)

Date

2026-07-14

Amended

2026-08-10 by ADR-142

Authors

Netresearch DTT GmbH

Context 

nr_llm had no way to measure the quality of the answers it produces. There were connectivity probes (TestPromptResolverService) and a property-based fuzzing suite, but no golden prompts, no graders, and no regression harness — so a model or configuration change could silently degrade answer quality with nothing to catch it. The reference extension aim runs LLM-as-a-judge grading and routes by grade; nr_llm had neither the measurement nor the feedback loop.

Two constraints shaped the design. First, evaluation must never run in the request path: it is an operator activity that spends time and, for the judge, tokens. Second, it must not enlarge the audited public service surface (ADR-028) without cause.

The building blocks already existed: DI-tag discovery with a tagged-iterator registry (nr_llm.configuration_presetADR-056), the CompletionService for model calls, and ModelSelectionService for criteria-based routing.

Decision 

Add an opt-in evaluation layer — declarative golden sets, pluggable graders, a run/aggregate service, result persistence with regression detection, and a CLI — none of which touches the request pipeline.

  1. Golden sets via DI tag. A consumer implements GoldenPromptSetProviderInterface (tag nr_llm.golden_prompt_set, auto-applied by AutoconfigureTag, mirroring ADR-056) and returns GoldenPromptSet value objects. GoldenPromptSetRegistry collects them through a tagged iterator and fails fast on duplicate identifiers. Each GoldenPrompt carries deterministic Assertion s (exact / contains / regex / json_schema) and an optional reference answer. nr_llm ships one example set (nr_llm.smoke) as the pattern to copy and a runnable target.
  2. Graders behind an interface. GraderInterface has two implementations. DeterministicGrader (the default) evaluates the assertions with no LLM call and no tokens; its json_schema matcher is a lightweight structural check (required keys + per-key type), deliberately not a full JSON Schema draft validator, to avoid a runtime dependency. LlmJudgeGrader is opt-in: it asks a judge model through the existing CompletionService for a {"score", "reason"} verdict and handles a malformed or failed judge response defensively. GradingService selects the grader by identifier and falls back to the deterministic grader for an unknown one, so evaluation never silently spends tokens.
  3. Run and aggregate. EvaluationService runs a set against a model — one CompletionService call per prompt — grades each response, records the wall-clock latency, and aggregates to a pass rate and mean score. It neither persists nor compares, which keeps it unit-testable without a database.
  4. Persistence + regression detection. Runs are stored in the new tx_nrllm_eval_result table (a UI-less result log, no TCA, mirroring tx_nrllm_service_usage) as aggregate summaries plus a JSON snapshot of the per-prompt outcomes. RegressionDetector compares a run against the previous run for the same (set, model) and flags a regression when the pass rate or mean score falls beyond a configurable RegressionThresholds tolerance (default 0.1 absolute).
  5. Quality dimension as a routing hook. The quality signal is exposed as an opt-in hook, not a change to ModelSelectionService. EvaluationQualityScoreProvider derives a per-model quality score from stored results (latest run per set, averaged), and QualityAwareModelSelector re-ranks that service's existing candidate list by score. ModelSelectionService is unchanged, so the cost/latency selection modes behave exactly as before; nothing routes through the hook unless a consumer opts in. See Consequences for why the deeper integration is deferred.
  6. CLI, not request path. nrllm:eval:run runs a set, prints the per-prompt gradings and the aggregate, saves the run, and reports the regression verdict (--fail-on-regression makes a regression a non-zero exit for CI). Registration is via the console.command tag — made public by TYPO3's ConsoleCommandPass, so it adds no public: true override to the audited surface (ADR-028).
  7. No public-surface growth. The persistence class is instantiated directly in its functional test (its only dependency is ConnectionPool) and autowired as an interface into the command elsewhere, so nothing in the subsystem needs to be a public service.

Consequences 

Positive 

  • Answer quality becomes measurable and regressions become detectable, on an operator's schedule, without any per-request cost.
  • Consumer extensions declare their own golden sets through the same tested discovery mechanism they already use for presets and tools.
  • The deterministic default spends no tokens; the judge is a conscious opt-in.
  • The public service surface is unchanged.

Negative / limitations 

  • The LLM judge spends tokens and uses whatever default chat configuration the admin has set up; selecting a dedicated judge model is a follow-up.
  • The json_schema grader is a structural matcher, not a full JSON Schema draft validator.
  • Quality routing is a hook only: QualityAwareModelSelector re-ranks candidates on demand, but quality is not yet a first-class sort key inside ModelSelectionService. Making it one — alongside provider priority and cost — is a deliberate follow-up, because pulling the eval subsystem into the core selection path would invert the layering (selection must not depend on the opt-in evaluation store) and change existing selection behaviour.
  • tx_nrllm_eval_result grows by one row per run; a retention/pruning policy is a documented follow-up.

Alternatives considered 

Golden sets as YAML files under Configuration/. Rejected for the same reasons as ADR-056: the DI tag reuses the established discovery mechanism, needs zero per-extension configuration, and gives compile-time class references instead of stringly-typed files.

A full JSON Schema draft validator for structured assertions. Rejected: it would add a runtime dependency for a capability the lightweight structural matcher already covers for practical golden-set assertions.

Wiring quality directly into ModelSelectionService now. Rejected for this slice: it inverts the layering and risks changing the behaviour of the existing selection modes. Delivered instead as an additive, opt-in hook with the full integration marked as a follow-up.

Storing results as file snapshots instead of a table. Rejected: a table fits TYPO3, is queryable for the per-model quality aggregate the routing hook needs, and matches the precedent of tx_nrllm_service_usage.

ADR-061: Skill trust levels, signed manifests, injection scanning 


Status

Accepted

Date

2026-07-14

Authors

Netresearch DTT GmbH

Context 

The skill and tool subsystems already have a solid, layered base:

  • Ingest (ADR-035) fetches SKILL.md behind a GitHub host allow-list, resolves refs to an immutable commit SHA, checksums each body, and arrives enabled = false for repo/marketplace skills.
  • Injection (ADR-036) re-verifies the checksum fail-closed at compose time, never uses the system role, and fences the block behind a guard preamble.
  • Tools (ADR-038, ADR-043) enforce a fail-closed allow-list, per-tool admin gating against the acting backend user, and a group enable-cascade.

Four gaps remain. The SHA-pin binds bytes to a URL but not bytes to a publisher identity. The ingest inspects structure but never looks at the prose for prompt-injection payloads. The instruction/data boundary rests on a guard sentence and message role, without an explicit data delimiter. And there is no immutable record of who imported or enabled which skill when — the sync overwrites its own state. Separately, network egress is decided per tool (probe_url hard-codes its own site allow-list) rather than declared per tool group, so a new or third-party tool's egress is governed by nothing.

Decision 

  1. Publisher trust levels, separate from support status. SkillTrustLevel (untrusted < community < verified < first_party) classifies a SkillSource's provenance. It is explicitly not support_status (which is not a safety signal, ADR-035). The level is denormalised onto each Skill at ingest exactly as support_status is, and an unknown/legacy stored value reads as the lowest level (fromStringOrUntrusted). Injection and the allowed-tools union are gated against a configurable minimum trust level (skills.minTrustLevel, default untrusted = accept every enabled skill): SkillComposer::effectiveSkills() — the single source of truth for both paths — drops any skill below the floor. This composes with, and does not replace, the existing enabled = false default: untrusted still requires an explicit admin enable.
  2. Manifest fingerprint over a full public-key signature. A source may declare an expected_fingerprint: the sha256 its whole discovered skill set must hash to (a canonical, order-independent digest over the identifier → body-checksum pairs, degenerating to one entry for a single-file source). SkillManifestVerifier recomputes it after collect and verifies with hash_equals before any skill is materialised; a mismatch fails closed — no upsert, no orphaning — leaving the last known-good skills untouched, and is audited. We chose a declared expected digest over a detached public-key signature deliberately: it binds a publisher-declared identity to the exact reviewed bytes (beyond the URL SHA-pin) with no key-management infrastructure. Full detached signatures over a signed manifest listing per-skill digests are a documented follow-up (see consequences).
  3. Prompt-injection scanning at ingest. PromptInjectionScanner runs a data-driven, auditable pattern set over each body and records the findings on the skill. Tiering is conservative to avoid over-blocking legitimate prose: only high-confidence jailbreak markers (instruction override, role reset, DAN/developer-mode personas, chat-template control tokens) force-disable the skill fail-closed at import — even a single-file source that would otherwise default enabled. Medium/low findings (secret-exposure verbs, guardrail-bypass wording, covert behaviour, long encoded blobs) only flag the record for review, since the repo/marketplace skills they most often appear on already arrive disabled.
  4. Explicit instruction/data channel separation. SkillComposer keeps the trusted guard preamble and additionally fences the untrusted bodies between explicit BEGIN/END UNTRUSTED SKILL DATA markers that label them as reference data the model must not execute as instructions. Message role remains defence-in-depth, not a trust boundary (ADR-036); the markers give the model an unambiguous boundary in-band.
  5. Immutable import audit trail. tx_nrllm_skill_audit records who / when / which source / which SHA / which checksum / which trust level / which scan result for every ingest outcome, enable, disable and fail-closed rejection. It is append-only by construction: SkillAuditRepository exposes record() and read helpers and offers no update or delete path, and the table carries no soft-delete column. Any purge is a separate, documented retention operation — never the regular write path. The trail is additive to the existing sync; no backend UI (UI-less log, like tx_nrllm_tool_state).
  6. Per-group egress policies. EgressPolicyService declares a network-egress scope per tool group (ADR-043) as data, fail-closed: a group with no entry resolves to ToolEgressScope::NONE and may make no outbound request. The one positive scope, own_site, resolves the instance's own site hosts through SiteFinder — the allow-listing probe_url previously hard-coded, now lifted to the group boundary and consulted by the tool through the shared gate. There is no free-form "any host" scope, so a new or mis-declared group can never egress to an arbitrary target.

Consequences 

  • ● Publisher identity is now a first-class, fail-closed control: trust gates injection and tool grants, a declared fingerprint binds the reviewed bytes to the publisher, and an unknown trust value reads as the lowest.
  • ● Prompt-injection payloads are caught at the door: high-confidence jailbreaks never reach an enabled state, and every body's scan result is retained for review and audit.
  • ● The audit trail makes the provenance of any skill that can reach a prompt reconstructable after the fact, and cannot be rewritten through the app.
  • ● Egress is declared per group and fail-closed; the network reach of the tool surface is now one auditable table, not scattered per-tool constants.
  • ◐ The audit table grows monotonically (one row per ingest/enable/disable). At the expected admin-driven cadence this is negligible; a retention command is a documented follow-up if ever needed.
  • ◐ Egress scope is keyed by the existing coarse group taxonomy, so system (which carries probe_url) is granted own_site even though it also holds non-egressing diagnostics. Those tools never call the gate, so the grant does not loosen them; a dedicated network group is a possible follow-up.
  • Deferred: full detached public-key signatures over a signed per-skill manifest (the fingerprint is a declared expected digest, not a PKI signature); an admin-review queue UI for medium/low injection findings; and a dedicated egress group split. Each is additive to the fail-closed base delivered here.

See ADR-035 and ADR-036 for the ingest / injection base, ADR-038 and ADR-043 for the tool runtime and groups, and the administration guide for operation.

ADR-062: Streaming Request Lifecycle 

Status

Accepted

Date

2026-07

Authors

Netresearch DTT GmbH

Context 

Every non-streaming provider call goes through the middleware pipeline (ADR-026: Provider Middleware Pipeline) and so inherits budget pre-flight (ADR-025: Per-User AI Budgets), usage accounting, and telemetry (ADR-058: Telemetry Middleware). Streaming did not: both LlmServiceManager::streamChat() and streamChatWithConfiguration() called the provider's streamChatCompletion() generator directly and returned it to the caller.

The consequence was a live budget hole. A streamed chat:

  • ran no budget pre-flight — an over-budget user could stream freely;
  • produced no usage row in tx_nrllm_service_usage, so streamed tokens were invisible to cost dashboards and to the very budget aggregate that is supposed to gate the next call;
  • produced no telemetry row in tx_nrllm_telemetry, so a streamed call had no latency, no outcome, and no correlation id on record;
  • had no fallback, not even in the window where one is still possible.

The pipeline cannot simply be reused. A PHP generator is lazy: calling streamChatCompletion() returns a suspended generator without running a line of it. Wrapping that as the pipeline terminal makes every middleware run against a stream that has not started — Budget would gate nothing meaningful, UsageMiddleware (which records after the terminal returns) would record zero tokens, and TelemetryMiddleware would measure a near-zero latency. This laziness is exactly why ADR-026: Provider Middleware Pipeline sanctioned streaming as a documented pipeline bypass.

Decision 

Introduce a dedicated streaming lifecycle rather than forcing streams through the response pipeline. A single private collaborator, NetresearchNrLlmServiceStreamingStreamingDispatcher, owns it; the manager's two streaming methods build an opener closure and hand off to it. The dispatcher is a wrapping generator — it never changes the public Generator<int, string, mixed, void> contract the seven providers and their consumers rely on.

The lifecycle has four stages:

  1. Budget pre-flight — eager. StreamingDispatcher::stream() runs the same BudgetService::check() gate as BudgetMiddleware before it returns a generator, so an over-budget caller is rejected at call time with a typed BudgetExceededException — not lazily on first iteration. This is the one part that must be eager: a caller that never drains the stream is never charged, but a caller that is already over budget must never receive a stream to drain.
  2. Provider selection with fallback — before the first chunk only. The dispatcher walks the primary configuration's fallback chain (shallow, exactly as FallbackMiddleware does) and primes each candidate generator (rewind()) inside a try/catch. Priming runs the provider up to its first yield — the HTTP request and first delta — which is the last moment a provider can still be swapped. A retryable failure here (ProviderConnectionException, or a 429 ProviderResponseException) moves to the next candidate; a non-retryable failure bubbles up unchanged. Once a chunk has been handed to the caller a swap is impossible, so fallback stops at the first chunk. That single asymmetry with the non-streaming pipeline is intrinsic to streaming, not a shortcut.
  3. Drain accounting — lazy. As the caller drains, the wrapper re-yields each chunk, appends it to a completion buffer, and stamps the time-to-first-token on the first chunk delivered.
  4. Settlement — in a ``finally``. Usage and telemetry are written in the drain generator's finally block, so they land on every exit path: normal completion, a mid-stream exception, and an abandoned generator (client disconnect or a consumer break). PHP runs a suspended generator's finally when the generator is destroyed, which is what makes early-break accounting work without a caller callback. An abandoned stream therefore records the partial tokens actually produced, never zero and never the full amount.

Token counts are estimated 

The seven streaming adapters yield only text deltas and return void — none emits a usage frame at stream end. Real per-token usage is therefore not available on this path today. The dispatcher estimates it with the ≈4 chars/token heuristic already used by RenderedPrompt::estimateTokens(): the caller passes the prompt character count on the context metadata (it holds the messages; the dispatcher never sees the payload, honouring the ADR-026: Provider Middleware Pipeline "context carries no payload" rule) and the dispatcher counts the drained completion text.

Recording an estimate is a deliberate improvement over the previous state, which recorded nothing at all — for budget enforcement an approximate figure is far better than a silent zero. Exact stream usage would require enabling provider-level usage frames (OpenAI stream_options.include_usage, Anthropic message_delta usage, …) and threading them out of the generator without breaking its string yield type — a follow-up, tracked separately, that would replace the estimate with the reported figure where a provider supports it.

Attribution 

Mirroring the non-streaming split (ADR-058: Telemetry Middleware):

  • Telemetry names the requested primary configuration; a pre-first-chunk swap shows as fallback_attempts > 0. cache_hit is always false — streaming never caches. A new nullable time_to_first_token_ms column carries the TTFT; it is NULL for every non-streaming row (there is no partial-response milestone to measure), deliberately distinct from a real 0 ms.
  • Usage attributes to the configuration that actually served — after a fallback swap that is the fallback's provider/model/uid, not the primary's. For an ad-hoc stream (a pinned provider with no configuration entity) the served provider comes from the context metadata the manager sets.

Scope 

  • New StreamingDispatcher (private service, autowired) and the LlmServiceManager wiring for both streaming entry points.
  • streamChatWithConfiguration() gains a trailing array $metadata = [] parameter so streamed calls can carry budget attribution; it is additive and the three-argument callers stay source-compatible. LlmServiceManagerInterface — a Category-1 public API — keeps its Generator return contract, so this is backward compatible.
  • tx_nrllm_telemetry gains the nullable time_to_first_token_ms column and TelemetryRecord a matching trailing nullable field.
  • ADR-009: Streaming Implementation and ADR-026: Provider Middleware Pipeline updated: streaming is no longer a documented bypass.

Alternatives considered 

  • Route the generator through the existing pipeline. Rejected: generator laziness makes every middleware fire against a not-yet-started stream (see Context). Budget, usage, and telemetry would all be wrong.
  • Change the provider yield type to carry a final usage object (Generator<int, string|UsageStatistics, …>). Rejected: the string yield type is part of the public capability contract; every consumer would have to defend against a non-string chunk. Real usage belongs on the generator return value or a side channel, tracked as the follow-up above.
  • Eager drain inside the manager, then hand back a plain array. Rejected: it defeats the entire point of streaming (memory efficiency, real-time output) and would change the public Generator return type.

References 

ADR-063: Provider Resilience — Circuit Breaker, Health, Idempotency 

Status

Accepted

Date

2026-07

Authors

Netresearch DTT GmbH

Context 

A comparison of this extension against a sibling agent framework surfaced a set of resilience features neither side had: a circuit breaker (stop hammering a provider that is down), provider health scoring (know which provider is currently healthy), and request idempotency (a retried request must not double-charge or produce a second, different answer). The middleware pipeline (ADR-026: Provider Middleware Pipeline) and the telemetry log (ADR-058: Telemetry Middleware) already give the seams to add these without touching provider adapters.

The same comparison listed OpenTelemetry as a gap. It is addressed here too — by deciding not to build it (see below).

Decision 

Circuit breaker 

A new CircuitBreakerMiddleware tracks consecutive failing calls per provider. After circuitBreaker.failureThreshold consecutive tripping failures the circuit opens: for circuitBreaker.cooldownSeconds further calls to that provider fail fast with a CircuitOpenException instead of waiting on a connection timeout. After the cooldown a single half-open probe is allowed; success closes the circuit, failure re-opens it.

Tripping failures are exactly the set FallbackMiddleware already treats as retryable — ProviderConnectionException (network / timeout / 5xx / retries exhausted) and a 429 ProviderResponseException (rate limit). Client errors (other 4xx), misconfiguration and unsupported-feature errors mean the provider answered; they are not a health signal and neither trip nor reset the circuit.

Pipeline placement — innermost, priority 20. This is the load-bearing design choice, so it is spelled out:

TelemetryMiddleware      110  observes every run
  IdempotencyMiddleware  105  replays a stored result by key
    CacheMiddleware      100  payload cache; short-circuits on hit
      BudgetMiddleware    75  pre-flight budget gate
        FallbackMiddleware 50 swaps configuration on retryable failure
          UsageMiddleware  25 records the served call
            CircuitBreaker 20 guards the actual provider call   (THIS)
              <terminal>
Copied!
  • Inside FallbackMiddleware. An open circuit throws CircuitOpenException, which FallbackMiddleware now treats as retryable. Because the breaker sits inside the fallback loop, that exception is raised from within $next($configuration) on each attempt, so Fallback catches it and advances to the next configuration/provider. A naïve reading puts the breaker "between Budget (75) and Fallback (50)"; that would be wrong — outside Fallback, an open primary circuit would abort the whole call before any fallback ran, the opposite of the intent (skip the sick provider, use a healthy one). The breaker therefore lives below Fallback.
  • Inside UsageMiddleware. The breaker wraps only the terminal, so the health signal reflects the pure provider call — usage bookkeeping (success-only, with its own error handling) never contaminates it, and a genuine success closes the circuit before any post-processing.

CircuitOpenException extends ProviderException (so it carries the ADR-053: One marker interface for all thrown exceptions marker interface); ProviderConnectionException is final, so extending that was not an option — instead FallbackMiddleware::isRetryable() gained one explicit instanceof arm.

Circuit state storage: cache, not a table. State (consecutiveFailures + openedAt) lives in the nrllm_circuit cache via CircuitBreakerStoreInterface (no hardcoded backend — the instance's Redis/Valkey is shared across web workers, so one worker tripping a circuit protects them all). Rationale: the state is inherently transient, self-decaying (a forgotten entry reads as closed, the fail-safe default), and needs no schema, no purge command, and no write on every call. A DB table would add write load on the hot path and a maintenance surface for data that is meant to be ephemeral. The half-open single-probe gate is pragmatic (refresh the open window before probing) rather than an atomic compare-and-set, which the cache backend cannot portably offer — a few extra probes after a cooldown is acceptable. The failure counter shares this posture: it is a non-atomic get-modify-set, so under heavy concurrent failure some increments are lost and the breaker trips a little late (the count still climbs about one per cooldown window) rather than never — acceptable for the same reasons.

Provider health scoring 

ProviderHealthService reads the existing telemetry log (tx_nrllm_telemetry, ADR-058: Telemetry Middleware) over a rolling window and computes a per-provider ProviderHealthScore (success rate + mean latency into one comparable 0.0–1.0 number; success rate weighted 4:1 over latency). No new write path and no second source of truth — telemetry already records success/latency per run, so health is a read model over it. Reads go through a dedicated ProviderHealthRepository; the telemetry recorder's own interface stays append/purge-only, as ADR-058: Telemetry Middleware intended.

The one built-in consumer is FallbackMiddleware, which asks ProviderHealthService::reorder() to prefer healthier providers among the fallback candidates. It is a hint, opt-in, and minimal-invasive:

  • Gated by health.reorderFallbackOFF by default. When off, the chain is returned untouched with no telemetry query, so the configured fallback order stays the default.
  • When on, it is a stable sort by descending health: providers of equal (or unknown) health keep their configured order, and no candidate is ever dropped. A provider with no telemetry scores neutral, never unhealthy — an un-exercised provider must not sink just for lack of data.

A pure config-order-primary "tie-break" would be a no-op on a totally-ordered chain (there are no ties to break), so health-primary reordering is offered as the opt-in instead; the default-off flag is what keeps existing behaviour identical.

Idempotency keys 

An optional idempotency key on any options object (AbstractOptions::withIdempotencyKey()) makes a repeated request return the stored result instead of calling the provider again. A new IdempotencyMiddleware (priority 105 — just inside Telemetry, outside Cache/Budget, so a replay short-circuits the behavioural stack and is not re-charged, yet is still observed) stores the result under the key and replays it on the next call with the same key. Failed calls and streaming generators are never stored.

Cache, not a table — and not an overload of the existing cache key. The task hypothesis ("make it a thin layer on CacheMiddleware's key") was investigated and rejected: CacheMiddleware is deliberately array-only (it persists array<string, mixed>), so it cannot round-trip the typed responses (CompletionResponse, …) that the chat/completion paths return — which is the case idempotency actually matters for. The dedicated middleware stores over its own nrllm_idempotency VariableFrontend, which serialises any response value, so idempotency works for every operation while staying cache-backed: idempotency results are transient and TTL-bounded by nature, so a table (with its purge/TCA surface) would be the wrong store.

OpenTelemetry — deliberately not built 

OTel exporters/collectors are out of scope for a TYPO3 extension. The extension should not own an OTLP exporter, a collector endpoint, or trace sampling configuration — that is the host instance's observability responsibility (the same instance that owns logging, APM, and metrics scraping). What the extension can own — durable per-request metrics — already exists as tx_nrllm_telemetry (ADR-058: Telemetry Middleware) with a correlation id per run; a host that runs OTel can scrape or forward that. Building an in-extension OTel pipeline would duplicate host infrastructure, couple the extension to an exporter SDK, and add configuration the operator already manages centrally.

Consequences 

  • Faster failure, healthier routing. A downed provider is skipped within one cooldown window instead of timing out on every call; with the opt-in reorder on, a flapping provider is de-prioritised among fallbacks.
  • Circuit state is cluster-wide but forgettable. On a shared cache backend every worker sees the same circuit; a cache flush resets all circuits to closed (a conservative retry), which is acceptable.
  • Health is advisory only. With health.reorderFallback off (default), provider selection is byte-for-byte unchanged. The reorder, when enabled, re-loads each candidate configuration once to resolve its provider — a cost paid only on the opt-in path, on the (already-failing) fallback route.
  • Idempotency is opt-in and best-effort. No key ⇒ no behavioural change. A cache miss/flush simply re-runs the call. It does not deduplicate concurrent in-flight requests — it replays a completed result.
  • One new retryable exception. FallbackMiddleware::isRetryable() now also matches CircuitOpenException; the pipeline order test and the fallback tests pin this.
  • Three new caches (nrllm_circuit, nrllm_health, nrllm_idempotency), all without a hardcoded backend, all in the nrllm cache group so a group flush clears them.

Alternatives considered 

  • Circuit state in a DB table (tx_nrllm_provider_circuit). Rejected: ephemeral, hot-path state does not want a table, a per-call write, or a purge command. Cache is the natural store and shares state across workers for free.
  • Circuit breaker outside FallbackMiddleware (priority  60). Rejected: an open primary circuit would abort the call before fallback, defeating the purpose. The breaker must be inside the fallback loop.
  • Health scoring on its own rolling-window state. Rejected: telemetry already records exactly the success/latency signal; a second store would duplicate it and drift. Read the log.
  • Health as a config-order-primary tie-break (never reorders). Rejected as a no-op: a totally-ordered chain has no ties, so this would never change anything. Opt-in health-primary reorder (default off) gives a real, operator-controlled behaviour without changing the default.
  • Idempotency as a thin overload of CacheMiddleware's key. Rejected: CacheMiddleware is array-only by design and cannot store the typed responses the valuable (chat) case returns. A dedicated middleware over its own VariableFrontend is the minimal store that works for every response type.
  • Idempotency in a DB table. Rejected: transient, TTL-bounded data belongs in the cache, not a table with a purge/TCA surface.
  • Build OpenTelemetry into the extension. Rejected: host-instance responsibility; the telemetry log already exposes per-request metrics a host OTel stack can consume.

Deferred / follow-ups 

  • A backend readout of circuit state and health scores (a diagnostics panel). The services expose the data; only a view is missing.

  • Per-operation idempotency TTL override via call metadata (the middleware uses a single window today).
  • Concurrent-double-submit dedup. The current get-then-run-then-set has no atomic reserve-on-miss — a portable one is not available through the cache FrontendInterface — so two genuinely simultaneous same-key requests are not deduplicated; only sequential retries are (see Consequences). A future revision could gate run+store with TYPO3CMSCoreLockingLockFactory.
  • Strict circuit-breaker failure counting under concurrency. The failure counter is a non-atomic get-modify-set (best-effort, the same posture as the half-open gate), so concurrent failures can lose increments and trip the breaker slightly late. A future revision could make it exact with the same TYPO3CMSCoreLockingLockFactory.

References 

ADR-064: Central Privacy Model 

Status

Accepted

Date

2026-07

Authors

Netresearch DTT GmbH

Context 

The extension is metadata-only by construction almost everywhere. Telemetry (ADR-058: Telemetry Middleware) stores no prompts or responses — only an exception FQCN on failure — and already bounds its growth with a retention purge ( nrllm:telemetry:purge ). But two tables do persist per-request content, and until now they did so unconditionally:

Both tables also carry pure-metadata columns (identifiers, counts, checksums, trust level, timestamps) that are not sensitive.

ADR-058 named the gap explicitly:

The central privacy model (retention tiers none/metadata/redacted/full) is a later workstream; this middleware is metadata-only by construction.

So there was no single, operator-configurable answer to "how much per-request content does this extension keep, and for how long?", and no purge for the two content tables — only telemetry had one.

Decision 

Introduce one central privacy model, read from the extension configuration and applied at every content sink before a write.

1. Four levels — a backed PrivacyLevel enum on a strict-to-loose scale:

  • none — drop content, keep metadata.
  • metadata — drop content, keep metadata (the default).
  • redacted — store a bounded, credential-scrubbed copy.
  • full — store content verbatim.

The enum exposes persistsContent() (true for redacted/full), requiresRedaction() (true for redacted) and a severity() ordering with strictest(a, b) so a global default and any future per-scope override combine with "strictest wins"none is strictest, full loosest.

2. Safe defaultPrivacyPolicy::level() returns PrivacyLevel::METADATA when the setting is unset or invalid. That is the behaviour the extension already had by construction, so an un-configured instance keeps content out of the database, not in it.

3. Two governed sinks. EvaluationResultRepository and SkillAuditRepository inject PrivacyPolicyInterface and pass their content columns through filterContent() before insert (details; scan_result and detail). null (dropped content) is stored as the empty string so the columns stay well-typed. Every metadata column is untouched.

4. Honest, bounded redaction. ContentRedactor (the redacted level) masks a small set of high-signal secrets — credential-bearing URL query parameters (reusing ErrorMessageSanitizerTrait, not a copied regex), obvious bearer / API tokens, and email addresses — and caps length at 2000 characters. Its doc block states plainly that it is a heuristic, not a guaranteed PII scrubber; operators who must not store content at all use none/metadata.

5. Centralised retention. Each content repository gains a purgeOlderThan(int $timestamp): int mirroring TelemetryRepository::purgeOlderThan() (run_date for eval results, crdate for the audit trail). A new command nrllm:privacy:purge (--days, defaulting to the configured privacy.retentionDays, floor 1) purges all three log tables — eval results, skill audit and telemetry — and reports the count per table. The retention window clamps a missing / zero / negative setting to 30 days: 0 must never mean "delete everything immediately". The existing nrllm:telemetry:purge stays for backward compatibility.

6. Configuration. Two settings under a new privacy category in ext_conf_template.txt: privacy.level (options[None|Metadata|Redacted|Full], default metadata) and privacy.retentionDays (int+, default 30).

Consequences 

  • ●● One operator-facing answer to "what is stored". A single setting, read in one place and enforced at every content sink, replaces the previous implicit, per-table behaviour.
  • ●● Safe by default. An un-configured instance stays metadata-only — the historical behaviour — so this is not a silent behaviour change for existing installs. It generalises the principle telemetry already followed.
  • Bounded, purgeable growth for the content tables too. The two content logs get the retention story telemetry already had; nrllm:privacy:purge bounds all three from the scheduler.
  • Strictest-wins ordering is ready for per-scope overrides. The severity() / strictest() API expresses tightening today even though only the global default is wired, so a future per-configuration override composes without a rework.
  • Redaction is deliberately shallow. redacted removes known secret shapes and caps length; it does not guarantee PII removal. The honest doc block prevents a false sense of safety, and none/metadata remain the answer when content must not be stored.
  • Two purge commands now exist. nrllm:telemetry:purge is kept for backward compatibility alongside the broader nrllm:privacy:purge . Operators scheduling the new command can retire the old one.
  • Lossy at redacted/full boundaries. At redacted the stored details JSON may be truncated or masked and is no longer guaranteed to round-trip; consumers that need the verbatim snapshot must run at full.

Net Score: +6 (2×●● +4, 2×● +2, 1×◐ +0.5, 1×✕ −1.5, 1×◑ −1).

Alternatives considered 

  • Per-table settings instead of one model. Rejected: it multiplies operator surface and drifts, and gives no single answer to the compliance question. One enum applied at every sink is the point.
  • A comprehensive PII scrubber. Rejected as dishonest for the scope: robust PII detection is a large, error-prone problem. A bounded heuristic with an explicit "not a guarantee" contract, plus none/metadata for store-nothing, is the truthful design.
  • Drop content unconditionally (no ``full``/``redacted``). Rejected: the eval snapshot and injection-scan output have legitimate debugging and provenance uses. Making retention a choice — with a metadata-only default — serves both the privacy-first and the diagnostics case.
  • Reuse ``nrllm:telemetry:purge`` for all tables. Rejected: overloading a command named for one table is surprising. A dedicated nrllm:privacy:purge owns cross-table retention; the old command stays for compatibility.

References 

ADR-065: Reduce the public service surface (ADR-028 follow-up) 

Status

Accepted

Date

2026-07-15

Supersedes

the count and Category 3 / tail rationale of ADR-028

Authors

Netresearch DTT GmbH

Context 

ADR-028 froze the public: true overrides in Configuration/Services.yaml at 45 and concluded "no reduction in count": every entry was said to be load-bearing because removing it would break either downstream consumers or the extension's own functional tests. The functional-test half of that argument rested on one premise:

"TYPO3 FunctionalTestCase::get() uses the Symfony container's ->get() lookup, which only resolves public services."

That premise is wrong. The testing framework ships a fixture extension typo3/testing-frameworkResources/Core/Functional/Extensions/private_container whose PrivateContainerWeakRefPass runs at TYPE_BEFORE_REMOVING and registers every private service (and private alias) into a public service locator. FunctionalTestCase::get() falls back to that locator:

public function get(string $id): mixed
{
    if ($this->getContainer()->has($id)) {
        return $this->getContainer()->get($id);
    }
    return $this->getPrivateContainer()->get($id);   // private services
}
Copied!

So $this->get(SomeConcrete::class) resolves a private service in functional and backend-E2E tests without any override. The repository in fact already relied on this: WizardGeneratorService was private (public: false interface alias, no public concrete) yet resolved by class name in green functional tests. ADR-028 Category 3 ("repositories must be public for FunctionalTestCase::get()") and the class-name-resolution tail were therefore public for a reason that never held.

Decision 

Keep public: true only where it is genuinely required, and privatise everything that was public solely for test resolution. A service needs public: true if, and only if, it is:

  1. part of the documented downstream LLM-API contract that consuming extensions resolve by class name or interface via $container->get() / a DI type hint; or
  1. a supporting-service interface alias consumers wire against (the concrete class is private); or
  1. a concrete-only documented surface with no interface (only PromptSnippetComposer, ADR-031); or
  2. a specialized standalone consumer API (speech / image in isolation); or
  1. resolved outside DI via GeneralUtility::makeInstance(), which only reuses the container-built, dependency-injected instance for public services.

Everything else — the repositories, the setup-wizard collaborators ModelDiscovery / ConfigurationGenerator, the read-only UsageAnalyticsService, and the concrete supporting services behind a public interface alias — is now private (autoregistered by the Netresearch\NrLlm\ namespace block). Functional and backend-E2E tests resolve them unchanged through the private container. No test code and no runtime behaviour changed; only container visibility did.

The reduced public surface (27) 

A. Documented downstream LLM-API contract — 7 concrete + 7 interface aliases = 14:

  • Service\LlmServiceManager (+ LlmServiceManagerInterface)
  • Provider\ProviderAdapterRegistry (+ ProviderAdapterRegistryInterface)
  • Service\Feature\CompletionService (+ Interface)
  • Service\Feature\VisionService (+ Interface)
  • Service\Feature\EmbeddingService (+ Interface)
  • Service\Feature\TranslationService (+ Interface)
  • Service\Feature\ToolCallingService (+ Interface, ADR-051)

Added to Category A after this ADR (count superseded by ADR-071: Public keyword-search facade over the retrieval cascade):

  • Service\Retrieval\KeywordSearchInterface (alias; the concrete KeywordSearchService stays private, ADR-071)
  • nr_llm.keyword_search.index_backed (named index-backed-only variant, ADR-071)

B. Supporting-service interface aliases (concrete classes now private) — 6:

  • Service\CacheManagerInterface
  • Service\UsageTrackerServiceInterface
  • Service\PromptTemplateServiceInterface
  • Service\LlmConfigurationServiceInterface
  • Service\BudgetServiceInterface
  • Specialized\Translation\TranslatorRegistryInterface

C. Concrete-only documented surface — 1:

  • Service\Prompt\PromptSnippetComposer (ADR-031, no interface)

D. Specialized standalone consumer API — 4:

  • Specialized\Speech\WhisperTranscriptionService
  • Specialized\Speech\TextToSpeechService
  • Specialized\Image\DallEImageService
  • Specialized\Image\FalImageService

E. Resolved outside DI via makeInstance() — 2:

  • Service\Tool\ToolRegistry — TCA itemsProcFunc in Form\Tca\ToolGroupItems (ADR-042).
  • Service\SetupWizard\ProviderDetector — the DataHandler hook Hook\ProviderEndpointNormalizationHook.

Total: 14 + 6 + 1 + 4 + 2 = 27 (down from 45).

What became private 

Removed from the public set (autoregistered private; injected via DI, resolved in tests via the private container):

  • Repositories (8): LlmConfigurationRepository, ProviderRepository, ModelRepository, TaskRepository, PromptSnippetRepository, UserBudgetRepository, SkillRepository, SkillSourceRepository.
  • Setup-wizard collaborators: ModelDiscovery (concrete; ModelDiscoveryInterface alias kept for autowiring, now private) and ConfigurationGenerator.
  • Supporting concretes behind a public interface alias: CacheManager, UsageTrackerService, PromptTemplateService, LlmConfigurationService, BudgetService, Specialized\Translation\TranslatorRegistry.
  • Service\UsageAnalyticsService (read-only Analytics reporting service; its interface alias was already private).

Consequences 

  • The audited public surface drops from 45 to 27. The PublicServicesPolicyTest count constant and this ADR are the audit trail; ADR-028's "no reduction" conclusion is superseded.
  • Consuming extensions that resolved a concrete supporting service by class name (e.g. $container->get(CacheManager::class)) must switch to the interface (CacheManagerInterface). This is a breaking change for those callers, acceptable pre-1.0 and consistent with the interface being the documented contract.
  • Tests are unchanged: the private container keeps $this->get() on a private service working. Any future test that needs a private service by class name works out of the box for the same reason.
  • Adding a new public: true still requires the three-part change from ADR-028 (service definition, ADR entry, EXPECTED_PUBLIC_TRUE_COUNT bump) — but the bar is now "does a production, non-DI caller or a documented downstream consumer need it?", not "does a test resolve it?"

Relation to ADR-028 

ADR-028 stays as the record of the original policy and the public: true enforcement test. This ADR supersedes its count and its Category 3 / tail rationale. The enforcement mechanism (Tests/Unit/Configuration/PublicServicesPolicyTest.php) is retained; only the expected total changed (45 → 27).

ADR-066: Criteria-mode configurations resolve in the service layer 

Status

Accepted

Date

2026-07-15

Authors

Netresearch DTT GmbH

Context 

ADR-055 and ADR-056 introduced LlmConfiguration records with two selection modes: fixed (a direct model_uid relation) and criteria (a stored ModelSelectionCriteria JSON; model_uid = 0, the concrete model chosen at call time). ModelSelectionService::resolveModel() was the resolver — but it had no production caller. Every *ForConfiguration() entry point (embedForConfiguration, chatWithConfiguration, chatWithToolsForConfiguration, completeWithConfiguration, streamChatWithConfiguration) obtained its adapter through getAdapterFromConfiguration(), which read $configuration->getLlmModel() — a plain getter — directly. For a criteria-mode record that relation is null, so the call threw ProviderException: Configuration "…" has no model assigned.

This surfaced live: nr_ai_search's embeddingConfiguration / chatConfiguration presets are criteria-mode, so the entire retrieval path failed the moment it reached the provider adapter.

Decision 

getAdapterFromConfiguration() — the single adapter choke point for every *ForConfiguration() path — resolves the model through ModelSelectionService::resolveModel($configuration), which returns the directly configured model unchanged for fixed mode and selects from the stored criteria for criteria mode. It still throws the same ProviderException (code 1735300100) when resolution yields no model.

The resolved model is not written back onto the configuration. The configuration is a repository-managed Extbase entity; calling setLlmModel() would mark it dirty and Extbase would persist model_uid at request end, silently converting a criteria-mode record into a fixed-mode one. Per-model cost analytics for criteria configs (UsageMiddleware reads getLlmModel() directly) therefore remain a separate, non-destructive follow-up.

Consequences 

  • Criteria-mode configurations work across embed / chat / tools / complete / stream without the caller pre-resolving a model.
  • ModelSelectionServiceInterface is a trailing, nullable constructor dependency of LlmServiceManager (autowired); when absent the method falls back to the raw getter, so existing 5-argument constructions keep compiling.
  • Cost analytics for criteria-mode configs fall back to the provider-reported model id (no per-model DB pricing) until the follow-up threads the resolved model through the call metadata.

See PR #372.

ADR-067: Solr per-language core — no language filter query 

Status

Accepted

Date

2026-07

Amends

ADR-049

Authors

Netresearch DTT GmbH

Context 

SolrSearchBackend (ADR-049) added fq=language:<id> to every Solr query, in both search() and fetchSource(). EXT:solr separates languages by per-language cores (core_de, core_en, …) whose index schema has no language field. selectUrl() already selects the per-language read core via the site's per-language solr_core_read override, so the language dimension is handled by core selection. The extra fq=language:<id> therefore filtered on a field that does not exist — Solr returned zero results for every query, even against a populated core.

Live-verified on the BMDV deployment: querying core_de with only {!typo3access}0,-1 returned 41 documents; adding fq=language:0 returned 0, and the Solr schema API reports No such field [language].

EXT:solr's only shared-core mode shares a core across sites of the same language (disambiguated by siteHash; see siteScopedUrl()), never across languages — so no language filter is needed there either.

Decision 

Drop the fq=language:<id> filter from both search() and fetchSource(). Language is selected by the per-language read core; access by fq={!typo3access}0,-1. The language field stays in fl (harmless — toEvidence() falls back to the query's languageId when the response omits it).

Limitation (recorded, not a regression): shared-core language separation is unsupported. EXT:solr does not produce that topology — one core per language is the configset default — so this is a documented boundary, not a gap.

Consequences 

  • Solr retrieval returns results on standard EXT:solr per-language-core setups (previously it was always empty).
  • The per-query filter contract is now {!typo3access}0,-1 only (plus type/uid on fetchSource) — consistent with ADR-049's stated public-only contract.
  • No API or configuration change; a pure query-construction fix.

ADR-069: Remove the unusable PromptTemplate stack 

Status

Accepted

Date

2026-07-16

Authors

Netresearch DTT GmbH

Context 

The extension shipped a PromptTemplate domain stack from an early design phase: the DomainModelPromptTemplate entity, its DomainRepositoryPromptTemplateRepository, the ServicePromptTemplateService (+ PromptTemplateServiceInterface), the ExceptionPromptTemplateNotFoundException, and the tx_nrllm_prompttemplate table in ext_tables.sql.

The stack was never usable at runtime and had no production consumers:

  • No TCA. Configuration/TCA/ has no tx_nrllm_prompttemplate.php and Configuration/Extbase/Persistence/Classes.php has no PromptTemplate mapping. Without either, Extbase cannot map the table, so every PromptTemplateRepository method fails at runtime.
  • Zero consumers. Nothing injected PromptTemplateServiceInterface or the repository. The only PromptTemplate references elsewhere were the unrelated Task::getPromptTemplate() / prompt_template string column and ADR-031's contrast with PromptSnippet.
  • No coverage. No functional test loaded the PromptTemplates.csv fixture; the repository had 0% coverage from every suite. The service was unit-tested only against a mocked repository, so the runtime gap never surfaced. This was found while closing zero-coverage gaps (GitHub issue #399).

The stack was superseded twice: ADR-031 introduced the lightweight PromptSnippet library for reusable prompt fragments, and the Task entity (with its prompt_template string field) covers predefined, editor-managed prompts. Adding TCA plus functional tests to resurrect the stack would invest in a surface no code uses.

Decision 

Remove the dormant PromptTemplate stack in full:

  • the DomainModelPromptTemplate entity and DomainRepositoryPromptTemplateRepository;
  • the ServicePromptTemplateService and ServicePromptTemplateServiceInterface, including the public interface alias in Configuration/Services.yaml;
  • the ExceptionPromptTemplateNotFoundException (used solely by the removed service);
  • the tx_nrllm_prompttemplate CREATE TABLE block in ext_tables.sql;
  • the unit tests and the PromptTemplates.csv functional fixture that covered only the removed classes.

Task's unrelated prompt_template string field, PromptSnippet, and ADR-031 are untouched.

Consequences 

  • BREAKING (public API). ServicePromptTemplateService and ServicePromptTemplateServiceInterface are removed from the DI container. Any external caller resolving them would have failed at runtime already (no TCA), so no working integration can break; the removal is nonetheless a public-surface change and is called out for completeness. Pre-1.0, this is acceptable.
  • Orphaned database table. tx_nrllm_prompttemplate is no longer declared. TYPO3 does not drop tables automatically; on existing installations the table becomes orphaned. Operators remove it via the database analyzer (Admin Tools > Maintenance > Analyze Database Structure). No upgrade wizard is provided — the table held no data any code ever wrote.
  • Public-service count: 27 → 26. Removing the PromptTemplateServiceInterface alias drops the audited public: true count from 27 (ADR-065) to 26. This ADR is the new count authority, superseding ADR-065's count exactly as ADR-065 superseded ADR-028's. The breakdown is now 14 + 5 + 1 + 4 + 2 = 26: ADR-065's Category B (supporting-service interface aliases) drops from 6 to 5. The TestsUnitConfigurationPublicServicesPolicyTest EXPECTED_PUBLIC_TRUE_COUNT constant and this ADR are the audit trail. ADR-065 remains the record of the 45 → 27 reduction.
  • One prompt surface, not two. ADR-031's "two prompt-related entities now coexist" no longer holds: PromptSnippet (fragments) and Task (predefined prompts) are the remaining surfaces.

ADR-070: User-less configuration resolution by identifier 

Status

Accepted

Date

2026-07-16

Authors

Netresearch DTT GmbH

Context 

Downstream extensions pin their LLM calls to a named LlmConfiguration record and dispatch through the *ForConfiguration() entry points. The documented lookup path, LlmConfigurationServiceInterface::getConfiguration(), enforces a backend-user access check — unusable from user-less contexts such as CLI commands, Symfony Messenger consumers, or anonymous frontend requests. Consumers therefore resolved records through LlmConfigurationRepository::findOneByIdentifier() directly (the pattern the Integration Guide documented in Step 5), which silently skipped two guards:

  • the isActive flag — a deactivated record kept serving traffic;
  • the beGroups access restriction — a record an admin restricted to specific backend groups was resolvable by anyone.

ConfigurationResolver already existed as the access-check-free resolution collaborator for the default-configuration path, with a deliberate refusal policy: in a context without a backend user, an access-restricted default is not auto-applied, because there is no user to enforce the group membership against.

Decision 

ConfigurationResolver gains getActiveByIdentifier(string $identifier): LlmConfiguration as the supported identifier lookup for user-less contexts. It throws typed exceptions instead of returning null:

  • ConfigurationNotFoundException — no record with the identifier exists;
  • ConfigurationInactiveException (new, implements NrLlmExceptionInterface) — the record exists but is deactivated;
  • AccessDeniedException — the record is restricted to backend groups.

Access-restricted records are refused in user-less contexts. This extends the resolver's existing default-path policy to identifier lookup: beGroups restrictions express "only these backend groups may use this configuration", and a context without a user cannot prove membership — resolving the record anyway would turn the restriction into a no-op exactly where nobody is watching (unattended workers). A consumer that needs an access-restricted configuration in a worker context must attribute the call to a user via the options-carried beUserUid (ADR-052) and resolve through the user-aware LlmConfigurationServiceInterface, or the admin removes the group restriction from the record.

Unlike the default path, no directly assigned model is required: criteria-mode configurations carry no model_uid and resolve their model at call time (ADR-066).

No pinned-client facade is added. The per-capability *ForConfiguration() methods are already the one-line pinned call; option building (attribution, detail levels, tool defaults) is consumer policy that a generic facade cannot guess.

Consequences 

  • User-less consumers get one supported lookup with the isActive and access-restriction guards applied, instead of re-implementing them (or forgetting to) around findOneByIdentifier().
  • Typed inactive vs. not found outcomes let consumers degrade differently (e.g. log-and-skip vs. configuration error).
  • The Integration Guide's Step 5 now routes through the resolver; the previous example also called a non-existent findByIdentifier() (actual method: findOneByIdentifier()).
  • Access-restricted configurations are unavailable to user-less callers by design — an intentional behaviour change against raw repository lookup, which ignored the restriction.
  • One new exception class on the public surface (ConfigurationInactiveException).
  • ConfigurationResolver stays a private, constructor-injected service: no public: true entry is added, so the audited count in ADR-028 / ADR-065 (as reduced by ADR-069) is unchanged.

ADR-071: Public keyword-search facade over the retrieval cascade 

Status

Accepted

Date

2026-07-17

Authors

Netresearch DTT GmbH

Context 

ADR-049 built the site-search retrieval cascade (ServiceRetrievalRetrievalService over the tagged nr_llm.retrieval_backend implementations: Solr, ke_search, indexed_search, database LIKE fallback). Every class in that namespace is private — the only public faces of the capability are the LLM tools site_rag_query / site_fetch_source. Yet ADR-050 explicitly promises keyword content-finding to downstream extensions ("a consumer that only needs keyword content-finding uses nr_llm alone"), and ADR-028 / ADR-065 require cross-extension consumption to go through the documented, audited public surface.

That gap has a demonstrated cost: nr_ai_search's hybrid retrieval binds its sparse arm to the private concrete service ServiceRetrievalSolrSearchBackend via an @-reference in its own Services.yaml and imports four non-contract classes (RetrievalQuery including its bounds constants, AccessContext, SearchBackendInterface, EvidenceSource). A private service id carries no semver signal: any rename or restructuring inside nr_llm breaks the consumer's container compile silently, across the consumer's whole version constraint.

Decision 

Close the gap with a deliberately narrow public facade instead of exposing the retrieval internals.

The contract 

NetresearchNrLlmServiceRetrievalKeywordSearchInterface with exactly two methods:

public function search(string $query, int $limit, ?int $languageId = null): array; // list<KeywordHit>
public function isAvailable(): bool;
Copied!

KeywordHit is a final readonly DTO carrying sourceId, title, url, excerpt, languageId (int), score (?float, backend-native, not comparable across backends) and pageUid (?int) — the fields of the internal EvidenceSource minus the backend label, so cascade internals can evolve without touching the contract.

Semantics 

  • Clamp, never throw. The query is trimmed and truncated to RetrievalQuery::MAX_QUERY_LENGTH; a query shorter than RetrievalQuery::MIN_QUERY_LENGTH returns an empty list; the limit is clamped to 1..RetrievalQuery::MAX_SOURCES; a negative language id is clamped to 0. Out-of-range input is a normal call, not an exception.
  • Public-only. The facade always searches with AccessContext::publicOnly() — hits are what the anonymous visitor could read.
  • Degrade to empty. Any backend failure yields an empty list; the facade never throws. Cascade order, first-available-wins, URL deduplication and the result cap are ADR-049 semantics, reused unchanged (the implementation composes RetrievalService).

The pinning question: index-backed-only mode 

A hybrid dense+sparse consumer (nr_ai_search's RRF fusion) pins an index-backed engine and must treat "index unavailable" as an empty sparse arm — fusing hits from the priority-0 database LIKE fallback would silently mix engines of incomparable relevance into the fusion. The facade therefore ships in two container registrations of the same implementation class:

  • The KeywordSearchInterface alias resolves the full cascade including the database fallback — the right default for "find the page about X" consumers.
  • The named service nr_llm.keyword_search.index_backed resolves a variant constructed with $indexBackedOnly: true that excludes the priority-0 tier, per the SearchBackendInterface::getPriority() contract ("the always-available database fallback uses 0, index-backed engines use higher values"). Its isAvailable() answers for index-backed engines only.

A named service variant was chosen over a second interface method so the contract stays at two methods and each variant gives a coherent isAvailable() answer for its own mode. Consumers wire it as a named argument, e.g.:

Vendor\Ext\Search\SparseArm:
  arguments:
    $keywordSearch: '@nr_llm.keyword_search.index_backed'
Copied!

Both registrations are public: true and part of the audited, semver-guarded surface.

Consequences 

  • Public-service count: 26 → 28. The interface alias and the named index-backed variant join ADR-065's Category A (documented downstream contract). This ADR is the new count authority, superseding ADR-069's count exactly as ADR-069 superseded ADR-065's. The breakdown is now 16 + 5 + 1 + 4 + 2 = 28. The TestsUnitConfigurationPublicServicesPolicyTest EXPECTED_PUBLIC_TRUE_COUNT constant and this ADR are the audit trail; ADR-065's Category A list is amended in place.
  • The facade constrains Retrieval refactors. The clamping, public-only and degrade-to-empty semantics plus the getPriority() > 0 index-backed distinction are now contract; cascade internals (backends, RetrievalService, EvidenceSource) remain private and free to change as long as the facade is preserved.
  • Consumers drop private references. nr_ai_search can replace its @Netresearch\NrLlm\Service\Retrieval\SolrSearchBackend binding and its RetrievalQuery / AccessContext / EvidenceSource imports with the interface (or the named variant) and KeywordHit, guarded by a normal version constraint bump.
  • Documentation. The facade is documented in Documentation/Api/KeywordSearch.rst.

Alternatives considered 

  • Publish the internals (SearchBackendInterface, RetrievalService, RetrievalQuery, ...). Rejected: far wider surface, freezes the cascade's internal shape pre-1.0, and repeats the coupling this ADR removes one level down.
  • A second interface method (searchIndexBacked()). Rejected: the mode would double every future method and leave isAvailable() ambiguous about which mode it answers for.
  • Constructor flag without a named registration. Rejected: a consumer could only reach the index-backed variant by redefining the service in its own container configuration — reintroducing exactly the unversioned wiring knowledge this facade exists to remove.

ADR-072: Retrieval-quality evaluation — golden questions and top-k hit rates 

Status

Accepted

Date

2026-07-17

Authors

Netresearch DTT GmbH

Context 

ADR-060 gave nr_llm a quality-evaluation layer for generated answers: golden prompt sets, graders, result persistence, and regression detection. It measures the model, not the retrieval in front of it — yet in a RAG pipeline the retrieval step decides which evidence the model ever sees, and retrieval changes (an embedding swap, a reranker, a chunking retune, a new lexical backend) can silently degrade what gets found with nothing to catch it.

A working methodology for exactly this already exists downstream: the nr_ai_search extension built a 48-question labeled retrieval-eval set over its BMDV corpus. Its method is corpus-agnostic and proven:

  • Question forms. MATCH questions share vocabulary with the target document; GAP questions are everyday rewordings with little vocabulary overlap — the class retrieval quality problems live in and the primary split for reporting.
  • Hard classes. Questions can be tagged with a known-difficult retrieval class (near-duplicate, specific-vs-general, prose-free, boilerplate, normal) for a secondary per-class breakdown.
  • Multi-target labels. A question lists ALL document ids that answer it; any of them counts as a hit.
  • Top-k document-level hit rate. The primary metric is the top-1 and top-3 hit rate — a hit means any of the k best-ranked distinct documents is a target — reported overall, split by form, and broken down by hard class.

The methodology (schema and scoring protocol) belongs in nr_llm so every consumer can measure a retrieval change; the questions themselves do not — labels only mean something against a concrete corpus, so the BMDV set stays in nr_ai_search.

The building blocks again already existed: the DI-tag provider/registry pattern and the persistence + regression machinery from ADR-060.

Decision 

Add the retrieval counterpart of ADR-060 — golden question sets, a pluggable retriever contract, document-level top-1/top-3 hit-rate scoring, and a CLI — reusing the ADR-060 persistence and regression machinery unchanged. nr_llm ships the methodology, not the questions.

  1. Golden question sets via DI tag. GoldenQuestion carries the question text, its QuestionForm (MATCH/GAP), the expected document ids (multi-target), an optional free-form hard class, and an optional answer gist (label documentation, never scored). A question with an EMPTY expected-document list declares that no indexed document answers it; it scores as a hit only when the retriever correctly returns nothing. GoldenQuestionSetProviderInterface (tag nr_llm.golden_question_set) and GoldenQuestionSetRegistry mirror the ADR-060 provider/registry pair. No built-in set ships — unlike golden prompts, golden questions are meaningless without the consumer's corpus.
  2. Pluggable retrievers. EvaluatableRetrieverInterface (tag nr_llm.evaluatable_retriever) is deliberately minimal — a question string and a limit in, ranked document ids out — so ANY retrieval pipeline can be measured: nr_llm's own lexical cascade, a consumer's vector retrieval, a reranked variant. The adapter owns the mapping from its native results to document ids, which must use the same identity scheme as the golden set's labels (e.g. chunk-id prefixes for chunked vector stores). nr_llm ships one adapter, LexicalSearchRetriever (identifier nr_llm.lexical), over the ADR-049 retrieval cascade — both a runnable target and the pattern to copy.
  3. Hit-rate scoring. RetrievalEvaluationService asks the retriever for an overfetched raw ranking per question (TOP_K * OVERFETCH_MULTIPLIER results), collapses duplicate ids to the first occurrence (so top-3 always means the three best DISTINCT documents, even when a retriever hands back one id per chunk — the overfetch keeps a chunk-grained ranking from collapsing to fewer than three documents), and scores top-1/top-3 hits over the three best distinct documents. RetrievalSetEvaluationResult aggregates the rates overall, by form, and by hard class. Like EvaluationService it neither persists nor compares.
  4. Persistence and regression via ADR-060, by mapping. RetrievalSetEvaluationResult::toSetEvaluationResult() maps a run onto the existing result model: the retriever identifier takes the model column, top-1 hit becomes the per-question pass, top-3 hit becomes the per-question score — so the stored passRate is the top-1 hit rate and the stored meanScore is the top-3 hit rate. The grader column is fixed to retrieval_hit_rate, which scopes the (set, model, grader) key so retrieval rates are never compared against prompt-grading scores. EvaluationResultRepositoryInterface, tx_nrllm_eval_result, RegressionDetector and RegressionThresholds are reused without change.
  5. CLI, not request path. nrllm:eval:retrieval <set> <retriever> runs a set against a retriever, prints per-question hits, the aggregate hit rates and both breakdowns, saves the run, and reports the regression verdict (--max-top1-drop / --max-top3-drop map onto the ADR-060 thresholds; --fail-on-regression makes a regression a non-zero exit for CI). No LLM is involved — a run costs one retrieval call per question.
  6. No public-surface growth. Everything is private; the command is made public by the console.command tag as in ADR-060, and the two provider interfaces are discovered via their DI tags. The audited ADR-028 count is unchanged.

Consequences 

Positive 

  • Retrieval quality becomes measurable with the same accept/reject discipline ADR-060 gave answer quality: a consumer labels a question set once, then every embedding swap, reranker trial or chunking retune is a before/after hit-rate comparison with regression detection in CI.
  • One retriever contract lets the same golden set measure competing pipelines (lexical vs. vector vs. reranked) side by side — the stored (set, retriever) history keeps their baselines separate.
  • The persistence and regression machinery is reused, not duplicated.

Negative / limitations 

  • The metric mapping overloads the stored columns' names: for retrieval runs passRate means top-1 hit rate and meanScore means top-3 hit rate. The dedicated retrieval_hit_rate grader value makes the reinterpretation explicit and keeps the histories separate.
  • The retrieval depth is fixed at top-3 (the methodology's deepest metric); configurable k is a follow-up if a consumer needs top-5/top-10.
  • Latency is recorded per question but not part of regression detection.
  • The by-form and by-hard-class breakdowns are reported by the CLI but not persisted individually — only the aggregate rates are stored, so regressions inside one class that cancel out across classes are not auto-detected.

Alternatives considered 

Shipping golden questions with nr_llm. Rejected: relevance labels are statements about one concrete corpus. The BMDV set stays in nr_ai_search; nr_llm ships the schema, the scoring protocol, and test fixtures only.

A separate retrieval-result table and regression detector. Rejected: the ADR-060 summary (two 0.0–1.0 rates per run, keyed by set/model/grader) fits retrieval runs exactly; a parallel subsystem would duplicate persistence, retention (ADR-064) and regression logic for no expressiveness gain.

Scoring at chunk level. Rejected: the methodology deliberately scores at document level (a document is identified by its chunk-id prefix) because "did the right document surface" is the question a retrieval change must answer; chunk-level ranks are an implementation detail of the store.

Reranking-aware metrics (MRR, nDCG). Rejected for this slice: top-1/ top-3 hit rates are what the established acceptance criteria use, are robust with multi-target labels, and stay interpretable for small sets. Graded-relevance metrics need graded labels the methodology does not collect.

ADR-073: First-party test doubles for consumer-facing interfaces 

Status

Accepted

Date

2026-07-17

Authors

Netresearch DTT GmbH

Context 

The feature-service interfaces are documented as the consumer entry points (ADR-051): a downstream extension depends on ToolCallingServiceInterface or EmbeddingServiceInterface and, in its own unit tests, substitutes a fake. Every consumer currently hand-rolls that fake. nr_ai_search carries a FakeToolCallingService and a FakeEmbeddingService under its own test namespace, re-deriving the canned-response and call-recording behaviour that any consumer of the same interface needs.

A hand-rolled fake also re-introduces the maintenance trap ADR-051 set out to remove: it drifts from the interface it imitates. When the interface grows a method the fake does not implement, the consumer's suite fatals — the same breakage that blocked nr_ai_search's 0.13 → 0.16 update.

Decision 

Ship maintained test doubles from nr_llm itself, in a new runtime-autoloaded namespace Netresearch\NrLlm\Testing\ (Classes/Testing/, mapped by the production autoload block, not autoload-dev) so consumers autoload them without nr_llm's dev dependencies:

  • FakeToolCallingService implements ToolCallingServiceInterface: a FIFO queue of CompletionResponse values, per-call recording for both methods, and a settable throwable.
  • FakeEmbeddingService implements EmbeddingServiceInterface: canned vectors plus per-call recording for the five provider-backed embed* methods (so a test can assert the LlmConfiguration was passed through) and canned returns for the four pure vector helpers, which a fake has no reason to reimplement.

Both implement the real interface, so PHPStan fails the build if a fake drifts from the contract — the drift ADR-051 could not prevent for a consumer's own copy is now caught here.

The fakes are excluded from container autoconfiguration (Classes/Testing/* in the Configuration/Services.yaml exclude list) and add no public: true override, so the audited public-service count (ADR-028 / ADR-065 / ADR-069) is unchanged. Their docblocks mark them as consumer test fixtures, not for production wiring.

Consequences 

  • A consumer deletes its hand-rolled fake and type-hints Netresearch\NrLlm\Testing\Fake* instead; the double tracks the interface automatically.
  • The Testing\ namespace is a supported public surface. Renaming a fake's properties or methods is a breaking change for consumers and belongs in a release note.
  • The fakes cover the tool-calling and embedding interfaces named in ADR-051. Other feature interfaces (completion, vision, translation) gain a first-party fake only when a consumer needs one.

ADR-074: Reciprocal Rank Fusion as a hosted utility 

Status

Accepted

Date

2026-07-17

Authors

Netresearch DTT GmbH

Context 

nr_ai_search fuses its dense (embedding) and sparse (keyword) retrieval arms with Reciprocal Rank Fusion (Cormack et al., 2009): rank-only fusion combines rankings whose scores live on incomparable scales — dense cosine similarity versus BM25 — without any score normalization. Its implementation is 55 lines, pure and dependency-free, and the sparse arm it fuses is nr_llm's own keyword-search facade (ADR-071).

The 2026-07 downstream-extraction analysis flagged the class as genuinely portable but deferred hosting it here: ADR-049 decided the retrieval cascade is first-available-wins with no cross-backend merging, so RRF would have been public API with zero callers inside nr_llm. The revisit trigger was a second consumer of rank fusion, or a fan-out-and-merge retrieval mode superseding ADR-049.

Every hybrid consumer that pairs its own dense arm with the ADR-071 sparse facade needs this same fusion step, and each copy re-derives the identical math. The maintainer pulled the second-consumer trigger forward: host the utility now so hybrid consumers share one implementation instead of each carrying a private copy.

Decision 

Host the class as Netresearch\NrLlm\Service\Retrieval\ReciprocalRankFusion with the signature identical to the nr_ai_search original — fuse(array $rankedKeyLists, int $k = 60, array $weights = []): array — so a consumer migrates by swapping the namespace import, nothing else. As in the original, PHP array-key coercion applies to the fused result: numeric-string keys (e.g. '42') come back as int.

  • Pure final class, no interface. The math has exactly one correct implementation; an interface would add an abstraction with nothing to substitute.
  • Newable, not a DI service. The class is stateless with a no-argument constructor; consumers instantiate it with new. It is excluded from container autoconfiguration in Configuration/Services.yaml and adds no public: true override, so the audited public-service count (ADR-028 / ADR-065 / ADR-071) is unchanged.
  • ADR-049 is unchanged. nr_llm's own retrieval cascade remains first-available-wins: RetrievalService does not fan out across backends and does not merge their results. ReciprocalRankFusion is a consumer-facing utility; no nr_llm code path calls it.

Consequences 

  • nr_ai_search (and any later hybrid consumer) deletes its own copy and imports Netresearch\NrLlm\Service\Retrieval\ReciprocalRankFusion; behaviour is bit-identical.
  • The class is supported public API surface: changing fuse()'s signature or tie-breaking semantics is a breaking change for consumers and belongs in a release note.
  • Should ADR-049's cascade ever gain a fan-out-and-merge mode, the fusion math is already in place; adopting it there would be a new ADR superseding ADR-049, not a change to this one.

ADR-075: Neutral cross-encoder reranker protocol 

Status

Accepted

Date

2026-07-17

Amends

ADR-050 (cross-encoder reranking placement)

Authors

Netresearch DTT GmbH

Context 

nr_ai_search measured (NRFE-3960, its ADR-029) that a cross-encoder reranker over the bi-encoder candidate pool lifts top-1 retrieval accuracy where the bi-encoder alone and a naive LLM reranker do not — on the real BMDV corpus from 6/9 to 8/9. It shipped the capability as a consumer-private client (RerankerClientInterface over its own RetrievedDocument DTO) plus an HTTP sidecar in its Build/reranker serving the one heavy dependency (sentence-transformers + torch) outside PHP.

Its ADR-029 kept that placement deliberately consumer-side because no second consumer existed. The revisit trigger has fired: cross-encoder scoring is wanted for nr_llm-side ranking too (upgrading embed-and-rank from bi-encoder to cross-encoder quality, e.g. link-target ranking), which makes it a shared capability — exactly the category ADR-050 assigns to nr_llm, as long as it stays stateless.

Decision 

1. Neutral protocol in nr_llm. Netresearch\NrLlm\Service\Rerank\RerankerInterface:

rerank(string $query, array $candidates): array
// $candidates: list<array{id: string, text: string}>
// return:      list<array{id: string, score: float}>
Copied!

No consumer DTO crosses the boundary. Implementations:

  • HttpReranker — speaks the sidecar contract (POST {endpoint}/rerank {"query", "documents": [{"id", "text"}]}{"scores": [{"id", "score"}]}, input order). Pools above the sidecar's batch cap (RERANKER_MAX_DOCUMENTS, default 128) are split into sequential requests; per-pair scoring makes the split score-neutral. A transport failure, non-200 status or off-protocol body throws the typed RerankerException (Service\Rerank\Exception) — nr_llm never decides degradation.
  • NullReranker — one entry per candidate in input order with a uniform score of 0.0. The uniform value carries no ranking signal (a stable sort preserves the caller's ordering) and keeps the result shape-identical to HttpReranker so consumer merge code needs no null branch.

2. Selection rule. RerankerFactory builds the RerankerInterface container service from the extension configuration: an empty rerankerEndpoint selects NullReranker, a configured endpoint selects HttpReranker with the configurable rerankerTimeout (default 30 s — a CPU cross-encoder can be slow for a wide pool). Unreadable configuration fails open to NullReranker. This mirrors the selection nr_ai_search's RerankerClientFactory made consumer-side.

3. Sidecar moves alongside the client. Build/reranker (app.py, Dockerfile, requirements.txt, README.md) now lives in nr_llm so client and server version together. The HTTP contract is unchanged.

4. What stays consumer-side. DTO mapping (id/text extraction, score attachment), the ordering merge (including how an unscored candidate ranks), the degradation policy on RerankerException (e.g. fall back to the pre-rerank cosine ordering), and any score-threshold gate (the score scale is model-specific).

Boundary with ADR-050. This supersedes the nr_llm-side reading of ADR-050 / nr_ai_search ADR-029 that cross-encoder reranking lives only in nr_ai_search. ADR-050's guardrail is untouched: the reranker is a stateless capability (candidates in, scores out) — no persistent index, no chunking, no reindex pipeline. nr_ai_search amends its ADR-028/ADR-029 in its own adoption change.

Public-service count: 29 → 30 (after ADR-076's 28 → 29). The RerankerInterface factory-built entry joins Category A (documented downstream contract). This ADR is the new count authority, superseding ADR-076's count. The breakdown is now 17 + 5 + 1 + 5 + 2 = 30 (PublicServicesPolicyTest).

Consequences 

  • A consumer depends on RerankerInterface and drops its own client, factory and sidecar copy; only its DTO mapping, merge, degradation and gate code remain.
  • Two new extension-configuration keys: rerankerEndpoint, rerankerTimeout. Both default to off/30 s — nothing changes until an operator opts in.
  • The sidecar container is an optional runtime dependency of nr_llm deployments that enable reranking; nr_llm itself never requires it.
  • RerankerException implements NrLlmExceptionInterface (ADR-053), so blanket consumer catch blocks keep working.

ADR-076: Document understanding — native-first, rasterize fallback 

Status

Accepted

Date

2026-07-17

Authors

Netresearch DTT GmbH

Context 

Two providers already implement DocumentCapableInterface (Gemini, Claude): they accept a whole PDF as a Base64 document content block in a single chat call and reason over it natively. Meanwhile nr_ai_search built a PDF ingestion enrichment (its ADR-034) that never uses that capability: it shells out to poppler (pdfimages / pdftoppm), rasterizes image-bearing pages, and sends each page through VisionServiceInterface. The renderer trio it wrote for that — PdfRendererInterface, PopplerPdfRenderer, PdfRenderingException — is consumer-agnostic: nothing in it knows about ingestion, chunking, or degradation policy.

The 2026-07 downstream-extraction analysis (issue #416) decided the ingestion pipeline stays in the consumer, but that when nr_llm grows document understanding it must be designed around the existing native capability, with rasterization only as the compatibility fallback.

Decision 

Netresearch\NrLlm\Specialized\Document\DocumentAnalysisService is the stateless "understand this document" primitive: analyzeDocument(string $pdf, string $prompt, ?ChatOptions $options).

Native path first. The service resolves the effective provider the same way LlmServiceManager::chat() would (explicit per-call provider, else the default configuration's provider type, else the registry default). When that provider implements DocumentCapableInterface and supports PDF, the whole document goes into ONE chat call as a Base64 document block. Native ingestion is preferred because the model reasons over the whole document — layout, cross-page references, figures in context — at one call's latency and attribution, instead of N per-page approximations stitched together.

Rasterization as fallback only. For providers without document support, the PDF is rasterized page-by-page and each page is read by VisionServiceInterface with the caller's prompt; the answers are concatenated with [Page N] markers. The rasterizer is the ported nr_ai_search renderer behind a new seam:

  • PdfRasterizerInterface — image-page inventory, single-page and whole-document rasterization to PNG blobs, availability probe.
  • PopplerPdfRenderer — the poppler-backed implementation, ported essentially unchanged (array-argv proc_open, no shell parsing, temp-stub cleanup in finally), extended by the whole-document renderDocument() the fallback needs.
  • PdfRasterizationException — the typed rasterization failure.

poppler is an optional system dependency. Declared in composer.json suggest (poppler-utils); it is only needed when the fallback runs. When rasterization is needed but the binaries are absent, the service throws the typed, actionable ServiceUnavailableException::rasterizerUnavailable() (install poppler-utils, or configure a document-capable provider). When the native path is taken, poppler is never touched.

What stays consumer-side, permanently: ingestion orchestration, enable/disable and cost-cap configuration flags, per-page degradation contracts ("a failed page keeps the plain-text extraction"), chunking and indexing. On the fallback path a failed page therefore fails the call — consumers own retry/degrade policy.

Public-service accounting (count authority) 

DocumentAnalysisService joins Category D (Specialized standalone consumer API) as a concrete public: true service; the PdfRasterizerInterface alias stays private (DI autowiring only). This supersedes ADR-071's count as the audited breakdown (ADR-028 / ADR-065 process):

  • Category A (documented downstream LLM-API contract): 16
  • Category B (supporting-service interface aliases): 5
  • Category C (concrete-only documented surface): 1
  • Category D (Specialized standalone consumer API): 5 — Whisper, TextToSpeech, DallE, Fal, DocumentAnalysis
  • Category E (resolved outside DI via makeInstance()): 2

Total public: true overrides: 29.

Consequences 

  • Consumers get whole-document reasoning in one call on Gemini/Claude without writing provider-specific document blocks themselves.
  • The fallback keeps the feature working on every vision-capable provider, at per-page cost/latency and without cross-page reasoning.
  • One more audited public service (29); the policy test locks the count against this ADR.
  • nr_llm gains an optional system-binary dependency surface (poppler), but only on the fallback path and typed-failing when absent.

ADR-077: Plain completion joins the named-configuration path 

Status

Accepted

Date

2026-07-17

Authors

Netresearch DTT GmbH

Context 

The three-tier model (Provider → Model → Configuration, ADR-001) reaches every chat-shaped and embedding capability through a *ForConfiguration entry point: chatWithConfiguration(), streamChatWithConfiguration(), chatWithToolsForConfiguration() and — since ADR-055embedForConfiguration() all resolve the adapter from a DB-backed LlmConfiguration and run through the middleware pipeline, so budgets are enforced and cost is attributed per configuration.

The high-level CompletionService did not. Its complete() family resolves only the instance-default configuration (chat() picks the active default, ADR-034). A consumer that needs several distinct named text configurations — a summariser, a classifier and a chatbot in one extension — could not target them by identifier; every plain completion went to the single default. The low-level LlmServiceManager::completeWithConfiguration() existed but routes through the provider's raw complete operation (no system-prompt shaping, no response-format normalisation, no per-user budget metadata), so it is not a drop-in for the message-based complete() path.

Decision 

Plain completion joins the configuration path. `LlmServiceManager::completeForConfiguration(string $prompt, LlmConfiguration $configuration, ?ChatOptions $options = null)` mirrors chat()'s default-configuration branch — it builds the system/user ChatMessage value objects, injects the configuration's skills, and threads the per-user budget and idempotency metadata from the options — but against the caller's chosen configuration instead of the resolved default. A pinned provider on the options is irrelevant on the configuration path and is dropped, exactly as chat() does. The method takes a typed ChatOptions rather than the low-level completeWithConfiguration() metadata/override arrays, so budget and idempotency parity is handled once inside the manager.

The high-level feature service follows. CompletionServiceInterface gains the named-configuration counterparts of its whole family: completeForConfiguration(), completeJsonForConfiguration(), completeMarkdownForConfiguration(), completeFactualForConfiguration() and completeCreativeForConfiguration(). Each applies the same option transforms as its instance-default twin (response-format normalisation, Markdown system-prompt augmentation, factual/creative presets) before delegating to the manager — the shared transforms are extracted into private helpers so the two paths cannot drift.

Method-based, not a ChatOptions field. Targeting a configuration is expressed as an explicit LlmConfiguration parameter, consistent with the entire *ForConfiguration family, keeping ChatOptions a pure scalar readonly DTO.

Consequences 

  • Completion consumers select a backend-managed configuration by identifier instead of relying on the single instance default; per-configuration budgets and cost attribution apply to plain completion like to every other capability.
  • Behaviour parity holds: the JSON/Markdown/factual/creative variants apply identical transforms on the configuration path as on the instance-default path, guaranteed by shared private helpers rather than duplicated logic.
  • CompletionServiceInterface and LlmServiceManagerInterface gained methods — implementers outside this repo must add them. In-repo the concrete services and the hand-written StaticCompletionService test double are updated.
  • The low-level completeWithConfiguration() (raw complete operation) is unchanged and remains available for callers that deliberately want the non-chat completion endpoint.

ADR-078: Budget pre-flight for the specialized image/speech services 

Status

Accepted

Date

2026-07-17

Authors

Netresearch DTT GmbH

Context 

Chat-shaped calls run through the provider middleware pipeline, so BudgetMiddleware (ADR-025) enforces per-user and per-configuration spend ceilings before any provider request. The specialized image/speech services (DALL-E, FAL, Whisper, TTS) dispatch HTTP directly from AbstractSpecializedService and bypass that pipeline.

ADR-057 gave the specialized option DTOs the beUserUid/plannedCost fields (via BudgetFieldsTrait / BudgetAwareOptionsInterface) and wired them into usage attribution — but deliberately deferred enforcement, recording that "routing these services through a budget pre-flight is a separate decision with its own trade-offs (no token cost model for FAL, multipart flows)" and leaving plannedCost carried-but-unused on the send path. This ADR is that deferred follow-up.

Decision 

Add a pre-flight budget gate to AbstractSpecializedService. A new protected `enforceBudget(?int $beUserUid, ?float $plannedCost, ?string $configurationIdentifier)`` mirrors ``BudgetMiddleware::handle()`: it resolves the named configuration (reusing the existing findActiveConfiguration()), calls BudgetServiceInterface::check() and throws BudgetExceededException before any HTTP dispatch when a limit is exceeded. Each concrete service calls it after option resolution and input validation, before building its request:

  • DallEImageServicegenerate(), generateMultiple() (from the options), and createVariations()/edit() (from the scalar beUserUid, no cost/configuration).
  • TextToSpeechServicesynthesize() (synthesizeToFile() / synthesizeLong() delegate to it, so they inherit the gate without double-gating).
  • WhisperTranscriptionService — all three entry points (transcribe(), transcribeFromContent(), translateToEnglish()); gating only some would leave an enforcement bypass.
  • FalImageServicegenerate()/generateMultiple(), reading beUserUid/plannedCost from its allow-listed options array (a new extractPlannedCost() mirrors the existing extractBeUserUid(); the payload builder drops both, so neither reaches the FAL API).

DeepLTranslator was initially left out because its options carried no budget fields. That exception did not survive review: a paid external call no cap can stop is not defensible, whatever the shape of its options. TranslationService now threads plannedCost and configuration through alongside the already-threaded beUserUid, and both translate() and translateBatch() run the same pre-flight as the image and speech services. (Amended 2026-07-20; the original decision is kept above so the change of mind is visible rather than rewritten away.)

Fail-open by construction. The gate hangs on a new optional trailing constructor parameter ?BudgetServiceInterface $budgetService = null. It autowires from the existing BudgetServiceInterface alias in production; when absent (unconfigured deployments, unit tests) enforceBudget() is a no-op. Even when wired, BudgetService::check() short-circuits to "allowed" for calls without a backend user or without configured limits, so nothing changes until an operator actually sets a cap.

Consequences 

  • Behavioural change, gated on configuration. Deployments that already set per-user or per-configuration limits will start receiving BudgetExceededException on image/speech calls once a cap is hit — previously those calls were only attributed (ADR-057), never blocked. Deployments with no limits are unaffected.
  • FAL and DALL-E variations/edit have no cost model. FAL publishes no static price list and the variations/edit endpoints carry no plannedCost, so cost caps cannot trip for those paths — only request-count/token caps enforce. Operators must not assume a cost ceiling protects FAL image spend.
  • Catch surface. BudgetExceededException lives in Exception\ (the shared budget exception, ADR-025), not Specialized\Exception\. Consumers that catch only SpecializedServiceException will not catch a budget denial — this is the intended shared-exception behaviour.
  • Consumers that hand-rolled their own specialized pre-gate (e.g. an extension guarding image/TTS spend itself) can drop it; check() has no side effects, so a transitional double-gate is idempotent and harmless.
  • The optional constructor parameter is a semver-minor change in the 0.x line, consistent with ADR-052/ADR-057; implementers that construct the specialized services by hand gain a nullable trailing argument.

ADR-079: First-party fakes for Completion, Vision and Budget services 

Status

Accepted

Date

2026-07-17

Authors

Netresearch DTT GmbH

Context 

ADR-073 shipped maintained, first-party test doubles for the tool-calling and embedding feature services under the runtime-autoloaded Netresearch\NrLlm\Testing\ namespace, because consumers were hand-rolling doubles that fatal the moment the interface grows. It deliberately deferred the remaining feature interfaces: "Other feature interfaces (completion, vision, translation) gain a first-party fake only when a consumer needs one."

That consumer now exists. nr_repurpose (podcast/diagram/story generation) consumes CompletionService and VisionService and gates image/speech spend through BudgetServiceInterface — and its hand-written BudgetServiceInterface double is exactly the one that broke across consumers when check() gained its ?LlmConfiguration parameter (the 0.20 signature change), the failure ADR-073 exists to prevent.

Decision 

Ship three more first-party fakes, extending ADR-073 to the surfaces a concrete consumer now uses:

  • FakeCompletionService — the six CompletionResponse-returning methods (complete, completeFactual, completeCreative and their *ForConfiguration twins from ADR-077) draw from a FIFO $responses queue; completeJson* and completeMarkdown* return canned properties. Every call is recorded.
  • FakeVisionService — the four string-or-array methods return the canned string echoing the caller's arity (single image → string, batch → one per input); analyzeImageFull returns a canned VisionResponse.
  • FakeBudgetServicecheck() returns a canned BudgetCheckResult (allowing by default) and records every call.

Each is a plain final class (not readonly — the canned-value and call-recording properties are set by tests) implementing the real interface, so PHPStan level 10 keeps the double in lock-step with the production contract. Each carries a one-shot $throwable to exercise a consumer's error path.

Beyond ADR-073's named trio, this includes Budget (not a Service\Feature\ interface, but the one whose signature drift concretely broke consumers) and omits translation (no consumer needs it yet — the same demand-pull rule ADR-073 set).

Consequences 

  • Consumers type-hint their unit tests against a maintained fake instead of a hand-rolled double; a future signature change fails the build here rather than silently in every consumer.
  • The three interfaces each gain a second implementor (the fake). A future signature change now has a production + fake blast radius — intended: the fake failing PHPStan is the guardrail.
  • Purely additive and DI-neutral: Classes/Testing/* is already excluded from container autoconfiguration (Configuration/Services.yaml) and covered by the production PSR-4 autoload, so the public-service count (ADR-028) is unchanged and consumers get the doubles without nr_llm dev dependencies.
  • The Testing\ surface (property and method names) is a supported public API, as ADR-073 established: renames are breaking changes and belong in release notes.

ADR-080: Typed provider HTTP exceptions (authentication 401, rate-limit 429) 

Status

Accepted

Date

2026-07-18

Authors

Netresearch DTT GmbH

Context 

A provider's 4xx responses were all flattened into one class. The standard adapter path (AbstractProvider::handleResponse() and assertStreamingResponseOk()) mapped every 4xx — including 401 (authentication) and 429 (rate limit) — to a generic ProviderResponseException carrying the numeric httpStatus. A consumer that wanted to react differently to "the API key is wrong" versus "we are being throttled" (a controller turning either into user-facing copy, a retry policy) had to inspect getCode()/$httpStatus or, worse, re-parse the message string — the exact fragile string-matching ADR-053's marker interface was meant to end.

The mapping was also inconsistent across adapters: OpenRouterProvider::handleOpenRouterError() routed 401 to ProviderConfigurationException and 429 to ProviderConnectionException, so the same HTTP status produced a different class on OpenRouter than on the other six providers.

Decision 

Introduce two status-specific subclasses of `ProviderResponseException`: ProviderAuthenticationException (401) and ProviderRateLimitException (429). They are empty final subclasses — they inherit the constructor and the typed httpStatus / responseBody / endpoint fields unchanged. ProviderResponseException drops its own final so it can be extended; it stays otherwise identical.

A private AbstractProvider::clientErrorException() factory picks the class from the status (401 → authentication, 429 → rate-limit, every other 4xx → the base ProviderResponseException) and is used by both the buffered and the streaming 4xx branches, so the two paths never drift. OpenRouterProvider is realigned to the same 401/429 classes (402 stays ProviderConfigurationException, 503 stays ProviderConnectionException).

Backward compatibility is preserved by inheritance:

  • catch (ProviderResponseException) still catches a 401 or 429 — the new classes are ProviderResponseException.
  • catch (ProviderException) and catch (\Netresearch\NrLlm\Exception\NrLlmExceptionInterface) are unaffected.
  • getCode() still returns the HTTP status, so the retry/fallback (FallbackMiddleware) and circuit-breaker (CircuitBreakerMiddleware) checks that key off getCode() === 429 keep firing for the rate-limit class.

Consequences 

  • Consumers branch on the exception class (catch (ProviderRateLimitException)) instead of the status code or the message text.
  • Behaviour change on OpenRouter only: a 401 is now ProviderAuthenticationException (was ProviderConfigurationException) and a 429 is now ProviderRateLimitException (was ProviderConnectionException). Both remain ProviderResponseException/ProviderException, and 429 keeps getCode() === 429, so retry/circuit-breaker semantics are unchanged; only a consumer catching those two specific OpenRouter classes sees the more correct type. The realignment is a separate commit in the introducing PR.
  • ProviderResponseException is no longer final; the two shipped subclasses are the only intended extensions.
  • The exceptions carry no Retry-After value — none is parsed today; adding it is a separate change.

ADR-081: Agent run persistence and a durable event stream 

Status

Accepted (the event vocabulary has grown — see ADR-151)

Date

2026-07-18

Amended

2026-08-11 by ADR-151 and ADR-153

Authors

Netresearch DTT GmbH

Context 

A run of the tool-calling agent loop (ToolLoopService::runLoop()) existed only for the lifetime of one HTTP request. It returned a ToolLoopResult value object and was then gone. The only inspectable trace, RunTrace, is an in-memory collector the admin playground builds to stream steps to the browser; nothing was written to storage.

That is the missing foundation under every larger agent capability: a run cannot be resumed after a worker restart, queued for later execution, paused for a human approval, audited, or replayed by a consumer UI, because there is no persisted run to attach any of that to. Cross-call usage already carried a correlation_id (tx_nrllm_telemetry), but nothing promoted it into a first-class run.

Decision 

Persist each run and its steps in two UI-less log tables, tx_nrllm_agentrun (one summary row per run) and tx_nrllm_agentrun_event (one row per recorded step, ordered by sequence). Both follow the telemetry precedent (ADR-058): raw Doctrine access through AgentRunRepository, no Extbase model and no TCA, because a run log is written and read programmatically, never edited in FormEngine.

Drive persistence from the existing `RunTrace.onRecord` hook. A fail-soft AgentRunPersister opens a run (begin() → RUNNING), records each RunStep as an event as it is emitted (recordStep()), and settles the run to a terminal state (settleCompleted() / settleFailed()). The tool loop is not touched: the persister is wired through the same per-step callback the playground already uses to stream steps, so the loop's control flow is unchanged and unaware of it.

Type the vocabulary without inventing what the loop cannot emit. AgentRunStatus carries the full lifecycle the later epics need (queued, running, waiting_for_approval, waiting_for_input, completed, failed, cancelled), but this change only ever writes RUNNING → COMPLETED/FAILED. AgentEventKind is limited to the four kinds RunStep actually produces (request, llm, tool, assembled); richer kinds are added by the epics that emit them.

Fail-soft is a hard requirement. Every persister method logs and swallows a storage error; begin() returns null on failure, which the caller treats exactly as a null RunTrace callback — "record nothing". A database hiccup can therefore never break an otherwise-successful run. The streamed path settles the run in a finally block (mirroring StreamingDispatcher), so a client disconnect mid-stream cannot leave a run stuck RUNNING.

Consequences 

  • Playground runs (batch and streamed) now persist. The admin playground is the first consumer; the tables are the substrate the human-in-the-loop, batch/queue and feedback epics build on.
  • A run that exhausts its iteration cap or is denied by the budget guard both surface as COMPLETED with truncated = true, because ToolLoopService catches the budget denial internally and returns a normal result. Recording a budget denial as a distinct FAILED run is deferred to the human-in-the-loop epic, which reshapes the loop's exit paths.
  • Event payloads store the full RunStep snapshot, which includes the messages sent (the prompt). AgentRunRepository::purgeOlderThan() provides the retention hook; a scheduled purge command is a follow-up. Runs are admin-authored for now (the playground is admin-only), so the exposure is limited.
  • The persister depends on AgentRunRepositoryInterface, so it is unit-tested against a recording double; the raw SQL and schema are covered by a functional round-trip.

ADR-082: Schema-validated structured outputs with one repair round-trip 

Status

Accepted

Date

2026-07-18

Authors

Netresearch DTT GmbH

Context 

CompletionService::completeJson() only enabled JSON mode and ran json_decode, checking the result was valid JSON and an array. No business schema was enforced — a consumer that needed, say, {title, description, keywords[]} back got an untyped array<string, mixed> that could be missing keys or carry the wrong types, and a single malformed response threw with no recovery. That makes AI results unreliable to program against.

A lightweight structural JSON-Schema matcher already existed inside DeterministicGrader (ADR-060, the evaluation subsystem): top-level type, object required keys, recursive properties types. Its logic was exactly what a validated completion path needs, but it was private to the grader.

Decision 

Extract the grader's matcher into a shared `JsonSchemaValidator` (Service/Schema/) and have both DeterministicGrader and the new completion path use it — one matcher, no duplication. The behaviour is byte-for-byte the grader's (including the empty-object-decodes-to-[] handling); the grader now delegates to it.

Add `completeStructured()` (and its `*ForConfiguration` twin) to CompletionServiceInterface / CompletionService. Given a prompt and a subset JSON Schema, it:

  1. requests JSON mode and injects the schema into the prompt (an instruction to return only conforming JSON),
  2. decodes and validates the response with JsonSchemaValidator,
  3. on a decode failure or a schema mismatch, performs one controlled repair round-trip — re-asking with the invalid output and the schema,
  4. returns the validated payload, or throws InvalidArgumentException if the repair also fails.

Provider-agnostic by design. The guarantee is prompt-injection + local validation + one repair, which works on every provider (OpenAI, Claude, Gemini, Ollama, Groq, Mistral, OpenRouter) uniformly. Native provider structured outputs were deliberately not used here: OpenAI's response_format: json_schema requires strict schemas (additionalProperties: false and every property required), which arbitrary consumer schemas do not satisfy and which would 400 the request; Claude has no native parameter at all. Wiring native, per-provider enforcement — behind a schema-compatibility normaliser — is a separate, additive change and is noted as a follow-up, not a blocker. (ADR-128 delivered that follow-up: native emission behind a conservative compatibility profile, degrading to JSON mode; Claude via a forced tool. The prompt + local validation + repair architecture described here stands.)

Consequences 

  • Consumers get a reliable typed contract: a schema in, validated data out, with automatic single-shot repair. completeJson() is unchanged for callers that do not need a schema.
  • The matcher lives in one place; a fix to the structural rules now benefits both the grader and structured completions.
  • Validation is structural (the documented subset), not full JSON Schema draft semantics — no enum, minLength, pattern, oneOf etc. That is the same contract the grader has always offered; a full validator would add a runtime dependency and is out of scope. Superseded for structured completions by ADR-126: A named JSON-Schema subset, enforced strict, which enforces a named strict subset including those keywords; the grader and the ADR-105 input gate keep this lenient contract.
  • The repair round-trip costs at most one extra provider call, only when the first response fails. It is capped at one attempt to bound cost.
  • CompletionService and DeterministicGrader take the validator as a constructor dependency with a default instance, so existing direct constructions (tests) keep working and DI autowires the shared service.

ADR-083: Conversation sessions and memory 

Status

Accepted (ownership and configuration binding superseded by ADR-091)

Amended

2026-07-20 by ADR-091

Date

2026-07-18

Authors

Netresearch DTT GmbH

Context 

Completion is stateless per call: CompletionService::complete() builds a fresh [system?, user] message array every time. Anything conversational — a backend assistant, a multi-turn task — had to re-assemble and re-send the whole history itself, and there was nowhere to persist it. Consumers were re-implementing conversation memory badly and inconsistently.

Decision 

Add an explicit, persisted session model in two UI-less log tables, tx_nrllm_ai_session (one row per conversation) and tx_nrllm_ai_session_message (one row per turn, ordered by sequence), read and written by a raw-SQL AiSessionRepository — the telemetry pattern (ADR-058), no Extbase and no TCA. The turns are read back as AiSession / AiSessionMessage value objects.

Add a `ConversationService` (a public feature service beside CompletionService) that turns the stateless path into a conversation:

  • startSession() opens a session owned by the current backend user (resolved through the existing BackendUserContextResolverInterface, not a raw $GLOBALS read),
  • send() loads the prior turns, replays them plus the new user message to the provider via the unchanged LlmServiceManager::chat(), and persists the user turn and the assistant reply (with the reply's model and token usage).

The provider call is untouched — this only assembles the message array and records the turns around it. The user turn is persisted before the call, so a provider failure still leaves an honest record of what the user asked.

Retention is explicit and by inactivity. AiSessionRepository::purgeInactiveSince() deletes sessions (and their messages) whose last_activity predates a window, driven by a nrllm:session:purge command that mirrors nrllm:telemetry:purge. There is no implicit, unbounded "the model remembers everything": memory is a named session, scoped, purgeable, and cost-attributed (token counts per turn).

Consequences 

  • Consumers get a conversation primitive instead of hand-rolling history. The stateless complete*() methods are unchanged for one-shot callers.
  • Message rows store the conversation content (prompts and replies). That is the point (replayable memory), but it is privacy-relevant: retention is bounded by the purge command, and sessions are owned/attributed to a backend user. A scheduled purge task registration is a follow-up.
  • The system prompt is prepended on every turn: the session history stores only user and assistant turns, so re-adding it does not duplicate it, and omitting it would drop the system instructions from the second turn onward.
  • The user turn advances the session's message count immediately (before the provider call), so a failed call cannot leave the next turn reusing the same sequence number.
  • Context-window management (summarising or truncating a long history before replay) is not in this change — the full history is replayed. A windowing strategy is a follow-up once real conversation lengths are observed.
  • ConversationService depends on AiSessionRepositoryInterface, so it is unit-tested against a double; the raw SQL and schema are covered by a functional round-trip.
  • Public-service policy (ADR-028 count authority). This change adds two public: true overrides — the ConversationService concrete and the ConversationServiceInterface alias — a Category-A documented downstream LLM-API feature pair, exactly like the Completion/Vision/Embedding services. The session repository stays private. The audited count therefore rises from 30 to 32 (Category A 17 → 19); PublicServicesPolicyTest is updated to match, and this ADR supersedes ADR-075 as the count authority.

ADR-084: Human-in-the-loop tool approval with suspend and resume 

Status

Accepted (the trigger is widened by ADR-134)

Date

2026-07-18

Amended

2026-08-09 by ADR-134

Authors

Netresearch DTT GmbH

Context 

The agent loop (ToolLoopService) executes every tool the model calls immediately. That is correct for the 41 shipped tools, which are read-only. But a write or side-effecting tool must not run unattended — an operator has to approve it first. The loop is synchronous (it runs to a result or throws), so there was no way to pause it, get a human decision, and continue. The persisted AgentRun (ADR-081) already reserved the WAITING_FOR_APPROVAL status for exactly this.

Decision 

Opt-in marker, not a new interface method. A tool that needs approval implements the empty RequiresApprovalInterface marker. The 41 existing tools are untouched, so their behaviour is provably unchanged — the loop only pauses for a tool that opts in. (Adding a requiresApproval() method to ToolInterface would have forced a change to every tool and every test double.)

Suspend via a thrown control-flow signal. When a turn contains an approval-required call, ToolLoopService checks the whole turn before executing any of its calls (so a multi-call turn stays consistent) and throws ToolApprovalRequiredException carrying a SuspendedRunState — the serialised transcript up to the assistant tool-call turn, the pending calls, and the iteration/token counters. Using an exception keeps runLoop()'s return type unchanged; the caller catches it before any generic catch (Throwable) so a suspension is never mistaken for a failed run. The check is inert for the existing tools, so the synchronous path is byte-for-byte the same.

Persist and resume. A new suspended_state column on tx_nrllm_agentrun stores the state; AgentRunRepository::suspendRun() is a non-terminal transition to WAITING_FOR_APPROVAL (distinct from finishRun(), which sets a terminal status and clears the state). ToolLoopService::resume() rehydrates the transcript, executes the pending calls (on approval) or feeds back a denial result (on refusal), then re-enters runLoop() with assembly skipped — the transcript already carries the system prompt and skills. The pre-suspend counters are folded into the returned result so the totals span the whole run. The playground exposes it through a resumeAction / nrllm_tool_resume route.

Consequences 

  • A side-effecting tool now gets a human gate for free by implementing one empty marker; nothing else about the tool changes.
  • Approval is per suspension (approve/deny the pending turn), not per individual call — the whole turn is held and resumed together, which keeps the provider transcript valid.
  • The resumed tool execution is recorded on the run's event stream (the playground step list), but not in the lean ToolLoopResult::$trace (ToolInvocation list), which only covers the continued loop. The event stream is the audit record; the invocation list is a summary.
  • suspended_state stores the transcript (including prompts). It is cleared on settle, and — like the rest of the run — bounded by the AgentRun retention purge.
  • The primitive lives in the runtime (ToolLoopService + the persistence layer), so any consumer — not only the playground — can suspend and resume; a downstream editor "review before the AI writes" flow builds on it.
  • Public-service policy (count authority). Extracting ToolLoopServiceInterface — so downstream extensions inject and test-double the loop (both runLoop() and resume()) rather than the final ToolLoopService — adds one public: true override, a Category B supporting-service interface alias (the concrete stays private). The audited count rises from 32 to 33 (Category B 5 → 6); PublicServicesPolicyTest is updated to match, and this ADR supersedes ADR-083 as the count authority.
  • Not in scope: a resume that re-plans (the model is simply continued from the approved result or the refusal), and per-call approval within a multi-call turn.

ADR-085: Guardrail pipeline for provider responses 

Status

Accepted

Date

2026-07-18

Authors

Netresearch DTT GmbH

Context 

nr-llm has good, targeted safety mechanisms (skill/tool egress, budgets, secret sanitisation on logged errors), but no general content-policy layer over a provider response. LLM output is untrusted content, and any editor-facing or autonomous use needs to be able to inspect it, rewrite it, block it, or route it for review — with a richer answer than a boolean.

Decision 

A guardrail is a `GuardrailInterface` whose checkOutput() returns a GuardrailResult verdict — ALLOW, REDACT, RETRY, REQUIRE_APPROVAL, or DENY. Guardrails are auto-collected through the nr_llm.guardrail DI tag (the same pattern as nr_llm.tool), so a new guardrail is active simply by existing under Classes/.

`GuardrailMiddleware` runs them in the existing ADR-026 provider pipeline, at priority 115 — outermost, above Telemetry (110) (superseded — see the 2026-07-19 update under Consequences: the guardrail moved to priority 90, inside the persistence layers). It screens every non-streaming CompletionResponse after the downstream chain produces it:

  • ALLOW passes on; REDACT rewrites the content and keeps screening (a later guardrail may still deny); DENY throws GuardrailViolationException; REQUIRE_APPROVAL throws GuardrailApprovalRequiredException; RETRY re-asks the provider once and re-screens the fresh response (capped at one retry).
  • It sits above Telemetry deliberately: a guardrail denial is a policy outcome, not a provider failure, so provider telemetry stays accurate.
  • Non-CompletionResponse results (embeddings, vision, raw payloads) pass through untouched.

Two reference guardrails ship active: SecretRedactionGuardrail (REDACT — masks secret-shaped strings a model may have echoed, reusing ErrorMessageSanitizerTrait plus API-key/Bearer patterns) and ProviderContentFilterGuardrail (DENY — turns a silent content_filter response into an explicit, catchable denial).

Consequences 

Update 2026-07-19 — corrected pipeline placement (priority 115 → 90). Placing the guardrail outermost meant it redacted the response only AFTER IdempotencyMiddleware (105) had already serialised the unredacted CompletionResponse into the nrllm_idempotency cache (24h, possibly a shared backend) — so a model that echoed a secret leaked it into persistence even though the caller saw the redacted value. The guardrail now runs at priority 90, INSIDE both persistence layers (Idempotency 105, Cache 100) and above the behavioural stack, so it redacts (or blocks) before anything is stored; a DENY/REQUIRE_APPROVAL throws before the store, so a blocked response is never a replayable result. Telemetry accuracy is preserved differently now that the guardrail sits inside Telemetry (110): the two guardrail exceptions implement a GuardrailPolicyException marker, and TelemetryMiddleware records such a policy outcome as a successful provider run (the provider produced a response; the guardrail refused to release it) — so a denial still does not distort the provider failure-rate. A side benefit: a RETRY now genuinely re-runs the provider instead of replaying the idempotency-cached response.

  • Consumers get allow/redact/deny/retry/require-approval over model output, not true/false. A denial is a typed, catchable exception (mirroring BudgetExceededException).
  • Behaviour change: a provider content_filter response now raises GuardrailViolationException instead of returning silently — the degraded/empty response surfaces rather than passing through.
  • Output only, for now. This screens the response. Screening the prompt (input redaction / injection detection) is a separate step: the prompt payload is captured in the pipeline's terminal closure, not on the immutable ProviderCallContext, so input guardrails require threading the messages through the context first — a scoped follow-up, not built here.
  • Streaming is not covered. StreamingDispatcher bypasses the middleware pipeline (ADR-062), so streamed responses are not screened. Covering them is a separate integration in the streaming path.
  • The REQUIRE_APPROVAL verdict is the seam to human review: on a plain completion it raises GuardrailApprovalRequiredException (distinct from a denial) so a consumer with a run/review context (the human-in-the-loop epic, ADR-084, or a review queue) can route it to approval instead of an error.

ADR-086: Guardrail enforcement gaps — playground verdicts and streamed output 

Status

Accepted

Date

2026-07-18

Authors

Netresearch DTT GmbH

Context 

ADR-085 added the guardrail pipeline: GuardrailMiddleware (priority 115) screens every non-streaming CompletionResponse and, on a DENY or REQUIRE_APPROVAL verdict, throws GuardrailViolationException / GuardrailApprovalRequiredException. Two gaps remained:

  • Tool playground. The tool loop (ADR-084) calls the provider through the same pipeline, so those guardrail exceptions can surface mid-run. The playground caught only ToolApprovalRequiredException and BudgetExceededException; a guardrail verdict fell through to the generic catch (Throwable) and returned an HTTP 500, so a policy outcome looked like a server error and the run row was recorded as a crash.
  • Streaming. A streamed response cannot go through the pipeline — a lazy generator can't be the pipeline terminal (ADR-062) — so GuardrailMiddleware never runs on streamed output. Streamed responses were an unscreened, unaudited blind spot.

Decision 

Playground: a guardrail verdict is a policy outcome, not a 500. The ToolPlaygroundController catches GuardrailViolationException and GuardrailApprovalRequiredException before the generic Throwable in all three run paths (batch runAction, resumeAction, streamed streamRun). The run is settled as blocked (so it is not left RUNNING) and a clean payload is returned — HTTP 200 with success:false and a status of guardrail_blocked (DENY) or guardrail_approval_required (REQUIRE_APPROVAL), naming the deciding guardrail and its reason. The streamed path emits the same as a terminal event instead of the generic error.

A full response-release resume for REQUIRE_APPROVAL (persisting the flagged response and re-delivering it on approval, mirroring ADR-084 for a completion rather than for pending tool calls) is not built here: the exception carries only the guardrail and reason, not a resumable state. The verdict is surfaced and recorded; releasing an approved response is a separate future step.

Streaming: end-of-stream guardrail audit. StreamingDispatcher collects the guardrail iterator (nr_llm.guardrail) and, once a stream drains successfully, screens the assembled completion via checkOutput() and records any non-ALLOW verdict (a structured warning). This is an audit, not enforcement: the chunks have already been yielded to the caller, so a DENY or REDACT cannot retract or live-redact them. The buffer is bounded (MAX_GUARDRAIL_BUFFER_BYTES, 50 000 bytes) so a pathologically long stream keeps streaming's memory benefit; a model echoing a secret does so near where it was given, so the leading window is enough to screen. Screening is fail-soft — a broken guardrail never turns a delivered stream into an error.

Consequences 

  • A guardrail decision in the playground is a clean, distinguishable outcome (blocked vs flagged-for-approval). The AgentRun row reflects it rather than a spurious failure since ADR-092, which stores the verdict as the run's termination reason — until then the row was written through the generic failure path and was indistinguishable from a crash.
  • Streamed output is no longer a guardrail blind spot: a leaked secret or a policy trip is recorded for audit even though it cannot be un-sent.
  • Live protection of a stream (redacting a secret before the chunk is sent, or denying mid-stream) needs a delta-oriented guardrail contract and per-chunk buffering — deliberately out of scope here and tracked as a follow-up.
  • Input-side screening (redacting or blocking the outgoing prompt) is a separate change on the send path, tracked in its own ADR.

ADR-087: Input-side guardrails — screening and redacting the outgoing prompt 

Status

Accepted

Date

2026-07-18

Authors

Netresearch DTT GmbH

Context 

ADR-085 established the guardrail pipeline for provider responses: GuardrailMiddleware screens every non-streaming CompletionResponse and a guardrail returns an ALLOW / REDACT / DENY / RETRY / REQUIRE_APPROVAL verdict. It screens output only, and said so: the prompt payload is not on the ProviderCallContext (ADR-026 keeps the context payload-free), so a middleware cannot see — let alone rewrite — the outgoing messages.

The prompt is untrusted too. A user can paste a secret (an API key, a credential-bearing URL) that should not be forwarded verbatim to a third-party provider, and an operator may want to block a prompt on policy before spending a call.

Decision 

An input guardrail is an ``InputGuardrailInterface`` whose checkInput() returns the same GuardrailResult verdict type as the output side. Implementers are auto-collected through the nr_llm.input_guardrail DI tag — separate from nr_llm.guardrail so an output-only guardrail (e.g. the provider content-filter, which reacts to a response attribute) is not forced to implement a meaningless input check.

Screening runs on the send path, not in the pipeline. An InputGuardrailScreener runs the input guardrails over the message list inside LlmServiceManager — before the pipeline, where the messages are reachable. This is the key difference from the output side: because the screener holds the payload, a REDACT verdict rewrites the prompt in place (a middleware could not). A DENY / REQUIRE_APPROVAL throws the same GuardrailViolationException / GuardrailApprovalRequiredException the output side uses, so a caller handles both identically; RETRY (re-ask the provider) has no meaning before the call and is ignored.

Screening is applied at the configuration-driven entry points — chatWithConfiguration, chatWithToolsForConfiguration, streamChatWithConfiguration — which is where chat() / streamChat() funnel once a default configuration resolves (ADR-034). The raw-string completeWithConfiguration (where complete() funnels) takes a prompt string rather than a message list, so it screens through a string variant that wraps, screens, and unwraps the prompt. For streaming, screening runs before the opener captures the messages, so a redaction reaches the provider and a DENY throws at call time, not on first iteration. The screener handles both typed ChatMessage and legacy array messages; a message with no string content (an assistant tool-call turn) passes through untouched.

SecretRedactionInputGuardrail ships as the reference implementation, applying the same secret masking to the prompt that SecretRedactionGuardrail applies to the response (shared via RedactsSecretsTrait). They are separate classes because a single class cannot implement both GuardrailInterface and InputGuardrailInterface — their TAG_NAME constants would collide.

Consequences 

  • The prompt is screened and can be redacted before it leaves the extension — the input complement of ADR-085.
  • Guardrail execution is deliberately split: output in the pipeline (ADR-085), input on the send path (here). The alternative — threading the payload onto ProviderCallContext — was rejected to keep the context payload-free (ADR-026); the trade is two locations instead of one.
  • Screening covers both the configuration-driven surface and the ad-hoc pinned-provider-key path. The ad-hoc branches of chat() / complete() / chatWithTools() / streamChat() (an explicit provider, no DB configuration — the ADR-034 escape hatch) screen the prompt as well, so every message-carrying send path is covered.
  • Input guardrails support ALLOW / REDACT / DENY / REQUIRE_APPROVAL. RETRY is ignored on the input side.

ADR-088: Live streaming redaction with a holdback buffer 

Status

Accepted

Date

2026-07-18

Authors

Netresearch DTT GmbH

Context 

ADR-086 added an end-of-stream guardrail audit: once a stream finishes, StreamingDispatcher screens the assembled completion and records any non-ALLOW verdict. It is audit-only — the chunks have already been yielded to the caller, so a secret a model streamed was recorded but not masked. The gap left open was live redaction: masking a secret before the byte is sent.

The hard part is chunk boundaries. A secret can be split across two chunks (sk-abcdef012 + 3456789…), so redacting each chunk in isolation misses it. Catching it requires not emitting a byte until enough following bytes have arrived to know it is not part of an in-progress secret.

Decision 

Redact the raw buffer fresh, emit its stable prefix. drain() accumulates the raw completion and, each chunk, redacts the whole raw buffer (redactStream() applies the output guardrails' REDACT verdicts) and emits only the redacted prefix beyond the last HOLDBACK_BYTES (128); the remainder is flushed at end-of-stream. Redacting the RAW text every time — never re-processing an earlier redaction marker — is essential: a marker such as sk-*** breaks the pattern's own character class, so re-redacting sk-*** + continuation would leave the continuation of a boundary-split key unmatched and leak it. Because the raw buffer re-matches a secret in full on every chunk, a complete secret always collapses to its marker; the holdback then withholds only a match still in progress at the tail (whose reach-back is an anchor plus the pattern minimum, far under 128), so no unredacted secret byte is emitted, including one split across chunk boundaries.

  • Only REDACT is actionable live. DENY / REQUIRE_APPROVAL cannot retract a sent stream; they remain the job of the end-of-stream audit (ADR-086), which still runs and records them. The audit's log wording now reflects that REDACT was masked live.
  • Only redaction-capable guardrails trigger the buffer. A guardrail opts into live redaction by implementing the StreamRedactableInterface marker (SecretRedactionGuardrail does). When none is registered — or only policy-only DENY guardrails are — the loop passes chunks straight through with no buffer and no latency.
  • Bounded (updated 2026-07-19). Live redaction runs over a bounded, self- certifying sliding window (StreamRedactionWindow): it re-redacts only a window (block-coalesced, so the rescan is O(n) not O(n²)) and prunes the settled front at a cut it certifies clean — `redact(head) . redact(tail) === redact(window)` — so memory stays bounded on an arbitrarily long stream WITHOUT ever passing a raw byte through. (The earlier design flushed and passed the tail through raw past a 50 KB cap, which leaked a secret positioned beyond it; that passthrough is removed.) The separate ADR-086 audit buffer still caps at MAX_GUARDRAIL_BUFFER_BYTES (50 KB).
  • Multibyte-safe. The emit boundary is backed off a UTF-8 continuation run so a codepoint is never split across two yielded deltas.
  • Usage/telemetry count the RAW provider output, unchanged — the redacted emitted length is not the billable token count.

Consequences 

  • Streamed secrets in the common shapes (sk-… keys, Bearer … tokens, credential-bearing URL params) are masked before delivery, closing the streaming blind spot the ADR-086 audit only recorded.
  • Cost: the last HOLDBACK_BYTES of every stream that has a redacting guardrail arrive at end-of-stream rather than incrementally — a small, bounded latency on the stream tail. Accepted as the price of live masking.
  • Limits: a credential whose anchor / URL-param name alone exceeds the holdback window and straddles the final boundary can still partially leak. A single UNBROKEN match longer than the 1 MB hard cap (with no clean cut anywhere) has its interior bytes DROPPED — a data-completeness loss, never a raw passthrough — so no secret leaks; only an adversarial >1 MB unbroken payload loses data (a benign unbroken blob factorises trivially and is pruned, not truncated). Correctness relies on the redactor collapsing a complete secret to a marker outside its own character class (so a partial anchor at the tail is the only unstable region) — true for the shipped SecretRedactionGuardrail — and is verified by a randomised property test (concat(deltas) === redact(fullRaw)).
  • DENY on a stream stays unenforceable — a hard block needs the non-streaming path.

ADR-089: Guardrail boundary completeness — reasoning, system prompt, vision 

Status

Accepted

Date

2026-07-18

Authors

Netresearch DTT GmbH

Context 

ADR-085/087 guard the model's answer (output) and the user turns (input). An adversarial audit of the merged layer found the secret-redaction guardrail — a defense against a model echoing a secret it was given, or a user pasting one — missed three reachable boundaries, so the same secret masked in one place leaked in another:

  • the model's reasoning / ``thinking`` block (surfaced in the playground glass-box, ADR-040) — screened on the answer, not the reasoning;
  • the system prompt — added after input screening, so it reached the provider unscreened on every path except completeForConfiguration;
  • the vision text prompt — vision() forwarded its text items to the provider without screening, on both sides.

Decision 

Cover every boundary the same secret can cross.

  • Reasoning. GuardrailResult gains an optional redactedThinking; SecretRedactionGuardrail masks both the content and the thinking block, and GuardrailMiddleware rebuilds the response with the redacted reasoning (null = leave as-is). Tool-call arguments are deliberately not redacted — they are functional parameters the tool consumes, and masking them would break the call.
  • System prompt. The six applySystemPrompt() call sites in LlmServiceManager route through applyAndScreenSystemPrompt(), which screens the final assembled message list — so the prepended system turn is screened too. Re-screening the already-screened user turns is an idempotent no-op.
  • Vision. vision() screens the text items of its VisionContent before dispatch, matching the chat/tool paths.

Consequences 

  • A secret is now masked (or the prompt blocked) across answer, reasoning, user turn, system prompt and vision text — the "enumerate all boundaries" completion of ADR-085/087.
  • embed() remains unscreened: its input is embedding text, not a chat prompt; screening/redacting it would corrupt the vector semantics. Documented as out of scope.
  • Redacting reasoning depends on the provider populating thinking (Ollama message.thinking, ADR-016); providers that fold reasoning into the content are covered by the content redaction already.
  • On the streaming paths the system prompt is screened inside the lazy opener (that is where applySystemPrompt runs), so a REDACT is applied before the provider send, but a DENY / REQUIRE_APPROVAL triggered only by system-prompt content would throw on first drain rather than at call time — unlike the eager user-turn screening. Latent: the shipped SecretRedactionInputGuardrail only REDACTs, which is correct here; a future DENY-returning input guardrail would need the effective system prompt hoisted and screened before the opener.

ADR-090: One extension until 1.0, with documented split seams 

Status

Accepted (its 1.0 re-evaluation is answered — see ADR-159)

Date

2026-07-19

Amended

2026-08-11 by ADR-159

Authors

Netresearch DTT GmbH

Context 

nr_llm has grown well beyond a provider abstraction. It now bundles several subsystems that are, in principle, independently useful:

  • the core three-tier abstraction (Provider → Model → Configuration), the middleware pipeline (fallback, budget, usage, cache) and the completion / embedding / vision feature services;
  • specialized services — DeepL / LLM translation, DALL·E / FAL image generation, Whisper / TTS speech (Classes/Specialized/);
  • the tool / agent system — the builtin tool set, RAG site-search retrieval, the tool loop, human-in-the-loop approval and agent-run persistence (Classes/Service/Tool/, ADR-042 ff., ADR-084);
  • the guardrail / redaction safety pipeline (Classes/Service/Guardrail/, ADR-085 ff.);
  • the backend UI — Tool Playground, analytics dashboard, setup wizard and skills management (Classes/Controller/Backend/).

Different consumers want different subsets: an agency embedding the provider core in its own product does not need the backend dashboards; a site that only translates does not need the agent stack or its attack surface. That makes a split into focused extensions (nr_llm core plus optional feature packages) an attractive long-term shape.

It is, however, the wrong move now. The extension is under rapid pre-1.0 development: the internal contracts between these subsystems still change often. Splitting today would freeze those contracts into public, cross-extension APIs prematurely, and impose coordinated multi-repo releases and an extension-version compatibility matrix — friction that would slow exactly the iteration that is still happening.

Decision 

Ship ``nr_llm`` as a single extension until the 1.0 release, and revisit the split with or before 1.0 — once the internal seams have stabilized.

Until then, keep the architecture split-ready rather than split: preserve clean module boundaries so a future extraction is a packaging change, not a re-architecture. The phpat architecture tests (Tests/Architecture/) already enforce the vertical layering — Controller → Service → Provider / Domain (e.g. controllers use the tool registry rather than concrete adapters, services do not depend on controllers or concrete provider adapters, domain models stay free of repositories/HTTP). Since Tests/Architecture/ModuleSeamTest.php they also police the horizontal seams between the feature modules. The enforced rules are directional, not symmetric: specialized and tool/agent may not depend on each other in either direction, guardrail may depend on neither, and nothing outside the backend package may depend on Controller or Widgets. Calls in the invoking direction stay allowed — specialized and tool code reaching a guardrail is how the safety pipeline runs at all, and the backend depends on everything by design. Cross-module coupling that would block an extraction now fails CI rather than only review.

Anticipated split seams (candidate extensions):

Package Depends on Scope
nr_llm (core) Provider → Model → Configuration, middleware pipeline, the completion / embedding / vision feature services, LlmServiceManager. The mandatory base of every other package.
nr_llm_specialized core Translation (DeepL / LLM), image (DALL·E / FAL), speech (Whisper / TTS), and their per-modality provider adapters.
nr_llm_tools core The builtin tool set, RAG site-search retrieval, the tool loop, human-in-the-loop approval and agent-run persistence.
nr_llm_guardrail core The guardrail / secret-redaction safety pipeline. May instead remain in core, since "secure by default" is a core promise.
nr_llm_backend core (+ installed feature packages) Tool Playground, analytics dashboard, setup wizard, skills management — the backend modules and their widgets.

A subsystem is a candidate for extraction only once all of the following hold:

  • its contract with core has been stable across several releases (few or no breaking changes);
  • a concrete consumer benefits from installing it separately (smaller footprint or reduced attack surface); and
  • the 1.0 public-API freeze is planned or in progress.

Consequences 

  • Now: one repository, one release pipeline, one version — fast iteration. The cost is a larger install footprint for consumers who want only a subset, and a broader default attack surface (mitigated by the tool availability gating and guardrail defaults).
  • Split-ready discipline: module boundaries must stay clean. The phpat architecture tests guard both the vertical layering and, since ModuleSeamTest, the horizontal seams — core reaching into the backend UI, or one feature module reaching into another, fails CI. A deliberate new dependency across a seam therefore has to change this ADR and the rule together, which is the point.
  • At 1.0: re-evaluate against the criteria above. That re-evaluation has happened — ADR-159 answers it against the code at the API freeze and confirms one extension, with the evidence and the numbers. The next one is due at the first minor after 1.0. If the seams have held, the split is largely a composer.json / ext_emconf.php repackaging plus moving files along the documented boundaries; if a consumer need is real before 1.0, a single package (most likely nr_llm_specialized or nr_llm_tools) can be extracted early without committing to the full split.

Alternatives considered 

  • Split now. Rejected: premature. It would freeze still-churning internal contracts as public APIs and add coordinated-release friction during the phase where iteration speed matters most.
  • Never split; stay monolithic. Rejected: the subsystems are genuinely separable and real consumers want subsets. Committing to a permanent monolith would, over time, invite the cross-module coupling this ADR exists to prevent.

ADR-091: Sessions are owned — an explicit actor context on every turn 

Status

Accepted

Date

2026-07-20

Supersedes

the ownership and configuration binding of ADR-083

Authors

Netresearch DTT GmbH

Context 

ADR-083 introduced conversation sessions and stated that a session is owned by a backend user. The implementation never enforced it. ConversationService::send() loaded a session by uuid and used it; the repository filtered by uuid alone. Any caller holding a uuid could read another user's conversation history and continue it — including through the public: true service any downstream extension can reach.

Two further gaps came from the same root, an implicit caller:

  • The bound configuration was decorative. startSession() stored an LlmConfiguration identifier that send() never read; the turn went through LlmServiceManager::chat(), which resolves the installation default. A session opened against a locally hosted, tool-free configuration silently continued against whatever default the installation had — a different model, a different budget and a different guardrail set than the conversation started with.
  • Sequence numbers were derived from a read-modify-write. $session->messageCount was read, used as the next sequence, and written back. Two concurrent turns produced two rows with the same sequence, and the index on (session, sequence) was not unique, so the database accepted them and the replay order became undefined.

Decision 

Every stateful entry point takes an explicit AiActorContext.

$actor = AiActorContext::backendUser($uid, $isAdmin, $groupIds);
$actor = AiActorContext::serviceAccount('nrllm-worker');   // CLI, scheduler, queue
$actor = AiActorContext::anonymous();                      // owns nothing, may do nothing

$session = $conversations->startSession($actor, 'Teaser für Seite 17', $config);
$reply   = $conversations->send($actor, $session->uuid, 'Kürzer, bitte.');
Copied!

The actor is passed, not inferred from $GLOBALS['BE_USER']. A queue consumer can therefore act for the user who queued the work instead of inheriting whoever happens to be logged in, and the decision is testable without a backend bootstrap.

send() enforces three rules in order:

  1. Ownership. The actor must own the session, be an administrator, or be a service account. A uuid is an identifier, never an authorisation.
  2. Configuration binding. The session's configuration identifier is resolved on every turn through ConfigurationResolver::getActiveByIdentifierForActor(), which applies the activity and BE-group guards against the actor rather than the ambient user. A configuration that was deactivated, deleted or newly restricted stops the conversation with an access error instead of quietly falling back to the default. Turns then run through the new LlmServiceManager::chatForConfiguration(), the message-list counterpart of completeForConfiguration().
  3. Attribution. The turn is attributed to the acting backend user unless the caller set an explicit owner, so per-user budgets apply to conversations exactly as they do to one-shot completions.

Sequence allocation moves into the repository, and the database decides. (session, sequence) is now a UNIQUE key; appendMessageAtNextSequence() reads the next free slot, tries to take it, and retries on a unique-constraint violation. touch() advances last_activity unconditionally but raises message_count only when it grows, so a slower concurrent turn cannot report the session back down. uuid is unique as well.

Resolving the identifier per turn was chosen over freezing a configuration snapshot at startSession(): a tightened budget, a narrowed tool set or a new guardrail policy must take effect on running conversations, not only on new ones.

Consequences 

  • Breaking change for downstream consumers. Both ConversationServiceInterface methods gained a leading AiActorContext parameter. In a 0.x line this is accepted rather than papered over with an implicit fallback, because the implicit path is exactly the defect: a caller that forgets the context would otherwise silently keep the old, unauthenticated behaviour.
  • A session opened without a configuration keeps the generic path and the installation default — the pre-ADR-083 behaviour for callers that never chose one.
  • ConfigurationResolver gains an actor-aware sibling to its user-less getActiveByIdentifier(). The two coexist: user-less callers (CLI resolving by identifier with no actor at all) keep the stricter refusal of restricted configurations.
  • The UNIQUE key on (session, sequence) is added to an existing table. An installation that already produced colliding rows through the race must resolve those duplicates before the database analyzer can apply the index.
  • Ownership is enforced in the service, not in the repository query. The repository keeps a uuid lookup; the service is the single place that decides entitlement, which keeps the rule visible and testable in one location.
  • Denying access raises AccessDeniedException. Session uuids are random v4 values, so distinguishing "unknown" from "not yours" leaks nothing an attacker could enumerate, and the distinction is what an operator needs in a log.

This ADR supersedes the ownership and configuration paragraphs of ADR-083; the session model, retention story and system-prompt handling described there are unchanged.

ADR-092: A run records why it ended, and cannot be settled twice 

Status

Accepted

Date

2026-07-20

Authors

Netresearch DTT GmbH

Context 

tx_nrllm_agentrun recorded what state a run was in and nothing about how it got there. Three consequences, all of them operational:

  • A budget stop and an iteration cap were indistinguishable. Both return a normal ToolLoopResult with truncated = true — the loop deliberately swallows the budget denial so the partial trace survives — and both are settled COMPLETED. The stored row could not tell an operator whether a run ended because the prompt needed more rounds or because the money ran out. Only a log line, thrown away with the next rotation, carried the difference.
  • A guardrail stop was recorded as a crash. settleFailed() stored the exception FQCN, so a policy decision looked exactly like a provider outage in the run table. ADR-086 claimed the row reflected the guardrail verdict; it did not.
  • Settling was unguarded. finishRun() updated by uid alone. The streamed path settles in a finally block precisely because a client disconnect can abandon a run, so a late settle landing on an already-completed run would overwrite its totals and error class.

Three enum cases — QUEUED, WAITING_FOR_INPUT, CANCELLED — also had no writer at all. CANCELLED in particular left operators with no way to retire a run that a dead PHP process left RUNNING, or an approval nobody would ever give.

Decision 

Status and reason are separate fields. AgentRunTerminationReasoncompleted, max_iterations, budget_exhausted, policy_denied, approval_denied, provider_failed, cancelled — is carried on ToolLoopResult from the loop's exit path and stored in a new termination_reason column. The status stays the coarse lifecycle state; the reason explains it. isRetryable() on the enum answers the question a retry policy actually asks: only a provider failure may be worth another attempt — an exhausted budget or a policy decision will not fix itself.

Guardrail stops are policy outcomes, not failures. settlePolicyStopped() records FAILED with policy_denied for an outright denial and approval_denied when a guardrail required an approval that was never obtained. The HTTP contract is unchanged (200 with success: false, ADR-086); only the persisted reason gains meaning — and ADR-086's claim about the row now holds.

Terminal is terminal. finishRun() updates only rows whose status is non-terminal and returns whether it transitioned. A duplicate or late settle keeps the first outcome and is logged at notice level rather than silently merged. This is the same conditional-UPDATE technique claimForResume() already used for the double-approval race (ADR-084).

Suspension is fail-closed for the caller. suspend() still swallows the store error — the persister is fail-soft by design — but now reports it. The playground fails the run instead of answering "awaiting approval", because an approval-gated tool is by definition side-effecting: promising a resume that cannot happen is worse than an honest error. Read-only recording stays fail-soft; a database hiccup must not break an otherwise successful run.

Cancellation is implemented, not merely enumerated. nrllm:agent:cancel <uuid> moves a non-terminal run to CANCELLED through the same guarded transition, dropping its resumable state. CANCELLED is distinct from FAILED: nothing went wrong, somebody stopped it.

Consequences 

  • ToolLoopResult gains a constructor parameter with a default, so existing positional constructions keep working; truncated is retained rather than derived, because "the answer is incomplete" and "this is why" are different questions and consumers already read the former.
  • AgentRunRepositoryInterface::finishRun() gains a parameter and returns bool instead of void — a breaking change to a DI-private interface, called only by the persister.
  • AgentRun gains terminationReason and terminationReasonEnum(). Unknown stored values return null rather than being coerced, matching how statusEnum() already guards forward compatibility.
  • Runs written before this change carry an empty reason. That is honest — the information was never recorded — and reads as "unknown", not as "completed normally".
  • WAITING_FOR_INPUT still has no writer. It stays in the enum as the reserved state for the queue work (roadmap P1), and this ADR does not pretend otherwise.
  • Guardrail approval remains terminal: the run ends with approval_denied rather than suspending for a decision, because GuardrailApprovalRequiredException carries no resumable state (ADR-086). Making it resumable means teaching the guardrail path to hand back the flagged content and the transcript, the way the tool-approval path already does — a separate change with its own ADR, not a side effect of this one.

ADR-093: One tool gate, in the loop — not in the controller 

Status

Accepted

Date

2026-07-20

Authors

Netresearch DTT GmbH

Context 

The tool subsystem enforces its policy in several places, and an audit found that three of those places disagreed with what the documentation claimed.

The per-configuration gate was in the wrong layer. A configuration's allowed_tool_groups and its skills' declared allow-list were applied by ToolPlaygroundController, not by the loop. That was defensible while the playground was the only entry point. It stopped being defensible when ToolLoopServiceInterface was published in 0.23.0 so downstream extensions could drive the loop: every such consumer bypassed the configuration's own restriction entirely and received the full globally-enabled set.

Two schema tools disagreed on what may be read. get_full_tca routed its decision through TableReadAccessService, whose sensitive-table denylist holds for administrators too. get_tca checked tables_select directly — and BackendUserAuthentication::check() returns true for every table for an admin, so get_tca described tx_nrllm_provider and the nr_vault tables to any admin-run loop, vault key references included.

A documented egress invariant was false. EgressPolicyService stated that a group without an entry in its map "cannot egress anywhere" and listed rag among the groups that "read local state only". Meanwhile the RAG tools reach SolrSearchBackend, which assembles a scheme://host:port URL from the site configuration and hands it to an HTTP client without ever consulting the policy.

Decision 

The loop is the chokepoint. ToolLoopService::resolveOfferedNames() now applies the per-configuration gate itself, alongside the global enablement intersection and the fail-closed admin filter it already applied. The caller's tool list is a request; the configuration is the grant. resume() runs the same resolution, so a run suspended before a configuration was tightened is re-checked at approval time.

ToolLoopServiceInterface, runLoop() and resume() are unchanged — both already receive the LlmConfiguration. The playground keeps passing the admin's checkbox selection and simply stops applying the gate a second time, so its observable behaviour is identical.

One table policy. get_tca routes both its list and its describe path through TableReadAccessService::canReadTable(). A denied table returns the same neutral Unknown TCA table. string as an unknown one, so the tool never confirms a table's existence.

The egress map tells the truth. A new scope, ToolEgressScope::CONFIGURED_ENDPOINT, expresses an operator-declared service host that is not a site base — OWN_SITE cannot describe a Solr host, so without it the map could only be satisfied by misdeclaring the group. rag maps to it, and SolrSearchBackend validates its assembled URL through EgressPolicyService::resolveConfiguredEndpoint(): http(s) only, no userinfo, exact host:port match against the configured host. A denial returns null, the method's established "not configured" path, which the retrieval service treats as an unavailable backend and skips.

Consequences 

  • The published ``ToolLoopServiceInterface`` contract narrows. A consumer that previously received tools outside its configuration's allowed_tool_groups now receives fewer. No shipped behaviour changes — the only production consumer already applied the intersection — but this is a deliberate tightening and belongs in the release notes.
  • get_tca no longer describes the extension's own or nr_vault's tables, to anyone, including administrators. That is the point.
  • The RAG egress gate is an audit and consistency gate, not a new confidentiality boundary. The Solr host was always operator-supplied from the site configuration and never model-supplied; what changes is that the invariant this class documents is now checkable in code instead of merely asserted. Overselling it would be dishonest.
  • Both new collaborators are optional constructor arguments so the existing lean test wiring keeps working. In production the container injects them; a unit test that omits them exercises the pre-existing gates only.
  • Classes/Service/Retrieval now references Classes/Service/Tool. That crosses a horizontal seam ADR-090 names but does not yet enforce. The alternative — duplicating URL validation inside the retrieval module — would be a second policy, which is the very failure this ADR is correcting.
  • Still open, and deliberately not addressed here: tools carry no data classification and providers no trust zone, so nothing yet prevents a diagnostics tool's output from egressing to an external provider. That is the next step and needs its own ADR.

ADR-094: Tool data classes and provider trust zones 

Status

Accepted (the axis now binds in both directions — see ADR-144)

Date

2026-07-20

Amended

2026-08-10 by ADR-144

Authors

Netresearch DTT GmbH

Context 

Tool safety rested on denylists: tables no tool may read, field names that look like credentials, config files that must not be served. Every audit found another name nobody had thought of — the extension's own vault-bearing tables, sys_file_storage.configuration, the encryptionKey, connection strings in a DSN. Six patches on a single day before the 0.23.0 release widened those lists again. The lists were not wrong; the shape was. Enumerating the bad is a race against every future field name, and the field always moves first.

What was missing is the other half of the question. A denylist asks "is this name dangerous". It never asks "dangerous to send where". A run against a locally hosted model and a run against a shared external service were offered the same 38-of-41 default-enabled tools, including environment variables, phpinfo and exception bodies.

Decision 

Every tool declares what kind of data it returns. ToolDataClass is a total order from publicContent up to secretAdjacent. A tool's class comes from an explicit declaration or, failing that, from its group's default — each group taking the class of its worst plausible member. An unknown tool or an undeclared group resolves to secretAdjacent: fail-closed, because an unclassified tool is precisely the case the classification exists to catch.

Classes are a property of the code, never of configuration. An administrator must not be able to relabel a tool to widen the gate.

Every provider declares where it runs. TrustZonelocal, privateHosted, externalEu, externalGlobal — is stored on the provider record and implies a ceiling on the data class a run reaching it may collect. One monotone comparison, not a 6×4 per-installation matrix: a matrix is configuration nobody gets right, and an operator who disagrees with a ceiling can move the provider to a different zone.

The zone is an operator declaration, not a technical control. Nothing stops an administrator labelling an OpenAI provider local; the extension cannot verify where an endpoint runs. What it buys is that the judgement is made once, by the person who knows the answer, instead of being implied by whichever tools happen to be enabled.

The zone that counts is the worst one reachable. FallbackMiddleware hands the call a different configuration when the primary fails, so a local primary with an external fallback would be offered secret-adjacent tools and then fail over, carrying the output with it. TrustZoneResolver therefore takes the least trusted zone across the configuration and its fallback chain — one level deep, because fallback is documented as shallow and walking deeper would model a path that cannot execute.

One gate decides. ToolCallPolicy evaluates all five conditions — registered, enabled, permitted for the user, within the configuration's groups, within the zone ceiling — and returns a typed ToolPolicyDecision rather than a silent absence, so the reason can be shown. Evaluation is a pure AND; the order only decides which reason is reported, and it runs cheapest and least revealing first. A tool that is both disabled and above the ceiling reports the disablement, so a denial never tells a caller who was already blocked that a trust-zone axis exists.

Enforcement ships in observe mode. tools.dataClassEnforcement defaults to observe: the decision is computed and logged, the tool is still offered. An upgrade must not silently strip tools from a working installation. The four pre-existing gates always enforce; the switch governs only the new axis, so turning it on can never loosen anything. Anything other than a literal enforce observes — a typo must not start removing tools from production.

Existing providers are stamped, not reclassified. A new provider defaults to the strictest zone. Applying that retroactively would be an outage in slow motion, so StampProviderTrustZoneUpdateWizard writes an explicit zone once, from the only available signal: an ollama adapter runs locally, everything else is external until an operator says otherwise. Afterwards the stored column is the single source of truth — nothing derives a zone from the adapter type at runtime, because that would make the declaration optional.

Consequences 

  • A database compare is required, plus running the upgrade wizard. tx_nrllm_provider.trust_zone is new; an un-stamped row resolves to the strictest zone, which in enforce mode removes the diagnostics, code and configuration groups from runs against that provider.
  • The ladder collapses two axes. "How secret" and "where may it go" are not the same question — a single scale cannot express "editorial content, EU only". An operator who needs them apart moves the provider between zones.
  • Personal data has no case of its own. The accounts tools return backend users, a GDPR concern rather than a secrecy level. Mapping them to secretAdjacent is conservative, not semantically precise; a proper PERSONAL_DATA concept needs its own axis, not a rank on this one.
  • One external fallback drags an otherwise local configuration down to the external ceiling. That is correct — the run really can reach that provider — but it will surprise operators who added a fallback purely for availability.
  • ToolInterface is not changed yet. Classifying by group plus seven explicit declarations covers all 41 builtins without 41 edits; promoting getDataClass() onto the contract is a later, announced breaking change, once observe-mode evidence exists.
  • isEnabledByDefault() and the "never-toggled group is enabled" default stay as they are. Flipping them would make a fresh install offer zero tools and buy nothing the ceiling does not already buy. The fail-closed default belongs on the new axis — a new provider is external until judged — which is where it is honest.
  • ToolCallPolicyInterface is published (public: true), raising the audited public-service count from 33 to 34. This ADR supersedes ADR-084 as the count authority.

ADR-095: One failure taxonomy for retry and circuit-breaker decisions 

Status

Accepted

Date

2026-07-20

Authors

Netresearch DTT GmbH

Context 

Three places decided "is this failure worth retrying against another provider": FallbackMiddleware::isRetryable(), CircuitBreakerMiddleware::isTrippingFailure() and StreamingDispatcher::isRetryable(). Each kept its own instanceof ladder, and they had drifted:

  • the fallback middleware retried a connection error, a 429 and an open circuit;
  • the circuit breaker tripped on a connection error and a 429, but not an open circuit (correctly) — and not on a 5xx;
  • the streaming dispatcher retried a connection error and a 429 only.

None of them retried a 5xx. A provider returning HTTP 500 repeatedly would neither fail over to a healthy sibling nor open its circuit — the two mechanisms built exactly for "this provider is unhealthy" both ignored the most common signal that it is.

The specialized services had a related defect: mapErrorStatus() collapsed a 429 into the same ServiceUnavailableException as every other error, so a rate limit was indistinguishable from an outage. ServiceQuotaExceededException existed for exactly this but was dead code, referenced only from tests.

Decision 

One vocabulary. FailureClass (connection, rateLimit, auth, configuration, clientError, serverError, circuitOpen, unknown) answers the two questions once: isRetryable() and tripsCircuit(). FailureClassifier maps a throwable onto it — a pure, static, directly-tested function recognising the provider exception family (ADR-080) and the PSR-18 network contract. The three call sites delegate to it and can no longer drift.

A 5xx is now a provider-side fault. serverError is retryable and circuit-tripping, so a 500-ing provider both fails over and counts towards opening its circuit. An auth, clientError or configuration failure is our fault, not the provider's, so neither retries nor trips. An already-open circuit never re-trips itself.

429 gets its own type on the specialized path. mapErrorStatus() throws ServiceQuotaExceededException for a 429 — wiring the dead factory — and records the upstream status under a statusCode context key that SpecializedServiceException::getStatusCode() exposes. The per-service error paths (Whisper, TTS) and the base executeRequest() catch the base SpecializedServiceException when re-throwing, so a typed 429 is no longer re-wrapped into a connection error one layer up.

Consequences 

  • Behaviour change: a 5xx from a provider now triggers fallback and counts towards its circuit breaker, where before it bubbled up. This is the intended correction — the mechanisms exist for exactly this failure — and is covered by new tests in both middleware.
  • Breaking: the specialized services throw ServiceQuotaExceededException on HTTP 429 where they previously threw ServiceUnavailableException. Both extend SpecializedServiceException, so a catch on the base class is unaffected; a catch specifically on ServiceUnavailableException for rate limits must be widened.
  • FailureClassifier deliberately does not yet classify the specialized exception family: those calls do not reach the retry/breaker middleware until the pipeline that routes them is generalised. getStatusCode() is the seam that will let it, and is added now so the data is recorded from this change on.
  • This is the first step of unifying the specialized-service lifecycle with the chat middleware pipeline; the pipeline generalisation (moving the call context off "provider" specifics so image/speech/translation calls can run through it) is tracked as the following step.

ADR-096: The pipeline configuration lives on the call context 

Status

Accepted

Date

2026-07-20

Authors

Netresearch DTT GmbH

Context 

The middleware pipeline that wraps every chat/embedding/vision call — budget, telemetry, cache, idempotency, guardrail, fallback, usage, circuit breaker — took the LlmConfiguration as a separate positional parameter of MiddlewarePipeline::run() and of every ProviderMiddlewareInterface::handle(). Six of the eight middleware merely forwarded it; it existed as its own parameter only so FallbackMiddleware could substitute a sibling configuration on a retryable failure.

That shape hard-wires the pipeline to callers that have an LlmConfiguration entity. The specialized services — DALL·E, FAL, Whisper, TTS, DeepL — do not: they are identified by provider and model strings and dispatch HTTP directly, which is exactly why they bypass the pipeline and, with it, telemetry, correlation ids, the circuit breaker and input guardrails.

Decision 

The configuration moves onto the context. ProviderCallContext gains a nullable configuration plus provider / model / configurationIdentifier strings. MiddlewarePipeline::run(context, terminal) and ProviderMiddlewareInterface::handle(context, next) drop the separate configuration parameter; $next and the terminal now receive the context. FallbackMiddleware swaps the configuration through ProviderCallContext::withConfiguration().

When the configuration entity is present it is the source of truth; when it is null the string fields are — telemetryProvider() / telemetryModel() / telemetryConfigurationIdentifier() encode that fallback in one place, so telemetry, usage and the circuit key work whether the call came from a configuration entity or a bare service descriptor. Three factories name the intent: for() (generic), forConfiguration() (an entity), forService() (provider/model strings, no entity).

ProviderOperation gains the specialized cases — image generation/edit/ variation, transcription, speech synthesis, translation — so every AI call is labelled from one vocabulary.

The class names keep the Provider prefix for now. Renaming ProviderCallContextAiCallContext and the sibling types is a pure cosmetic follow-up ( 40 references) and is deliberately not bundled into this behaviour-preserving change.

Consequences 

  • No behaviour change. This is a structural refactor: the chat path builds a context via forConfiguration() and every existing test passes unchanged in intent. TelemetryMiddleware reads provider/model/identifier from the context helpers rather than the entity, which also means the "requested primary configuration" survives a fallback swap for free.
  • Breaking for downstream pipeline callers. MiddlewarePipeline::run() and ProviderMiddlewareInterface::handle() changed signature, and a custom middleware or a direct run() caller must move the configuration onto the context. In-tree this was a mechanical migration across the middleware tests.
  • UsageMiddleware and CircuitBreakerMiddleware now tolerate a null configuration (a specialized call): usage attributes by the context's model string and the circuit keys off the context's provider.
  • This is the enabling step. It delivers no user-visible change on its own — the specialized services do not yet route through the pipeline. Adding the fail-closed dispatch seam and migrating those five services onto it is the following step, at which point they gain telemetry, correlation ids, the circuit breaker and input guardrails.

ADR-097: Specialized services dispatch through the shared pipeline 

Status

Accepted

Date

2026-07-20

Authors

Netresearch DTT GmbH

Context 

The specialized services — DALL·E, FAL, Whisper, TTS, DeepL — dispatch HTTP directly and bypass the middleware pipeline, so they get none of what a chat call gets: no telemetry row, no correlation id, no circuit breaker, no uniform error classification. ADR-096 made that reachable by moving the configuration onto the call context, so a caller without an LlmConfiguration entity can now drive the pipeline through a forService() context.

Decision 

AbstractSpecializedService takes the MiddlewarePipeline as a required dependency and exposes `runLifecycle(ProviderCallContext $context, callable $call)`, which runs the actual HTTP dispatch as the pipeline terminal. Each service builds a ProviderCallContext::forService(operation, provider, model) and wraps its dispatch in runLifecycle().

For a service context (no configuration entity, no budget metadata) most middleware self-disable: fallback has no chain, cache and idempotency have no key, the guardrail passes a non-completion result through, and the budget middleware is inert because the per-call budget is still enforced by enforceBudget() before dispatch. What the pipeline adds is a telemetry row with a correlation id and the provider circuit breaker — a flapping image or speech endpoint now trips and fails fast like a chat provider.

All five services are migrated: DALL·E (generate/generate-multiple/ variations/edit), FAL (generate/generate-multiple), Whisper (transcribe/transcribe-from-content/translate-to-English), TTS (synthesize) and DeepL (translate/translate-batch). Each wraps its dispatch in runLifecycle() with a forService() context labelled by its operation (ProviderOperation::ImageGeneration / Transcription / SpeechSynthesis / Translation).

Consequences 

  • Breaking: AbstractSpecializedService gained a required MiddlewarePipeline constructor parameter (after budgetService). A subclass or manual construction must pass it; an empty MiddlewarePipeline([]) is a valid pass-through for tests that do not exercise the lifecycle.
  • DALL·E calls now write a telemetry row (operation image, the provider and model, a correlation id) and are guarded by the circuit breaker. No other behaviour changes: usage is still recorded by the service's own trackImageUsage() (unifying that into a pipeline extractor is a later step), and the budget is still enforced by enforceBudget() before dispatch — the budget middleware stays inert for a service context, so there is no double check.
  • Classes/Specialized now depends on Classes/Provider/Middleware. That is the point — the specialized services join the shared lifecycle rather than reimplementing it.
  • Deferred, each its own step: applying input-guardrail screening to the specialized prompts; the fail-closed dispatch seam that makes a forgotten lifecycle wrapper throw rather than spend unmetered (it can only be switched on once all five services route through runLifecycle()); and folding the per-service usage recording into a tagged pipeline extractor.

ADR-098: Input-guardrail screening for specialized prompts 

Status

Accepted

Date

2026-07-21

Authors

Netresearch DTT GmbH

Context 

The chat send path screens an outgoing message list through the input guardrails before it reaches a provider (ADR-087), so a REDACT verdict rewrites a secret out of the prompt and a DENY / REQUIRE_APPROVAL throws before the call. The output guardrails run inside the pipeline (ADR-085), but they only ever see a model-generated CompletionResponse — the prompt payload is not reachable there, which is why input screening runs on the send path.

The specialized services — DALL·E, FAL, TTS, DeepL — now route their dispatch through the pipeline (ADR-097), but the pipeline still cannot screen their input: a specialized call sends a prompt string, not a CompletionResponse, and a middleware could not rewrite that prompt back into the terminal closure a REDACT verdict requires. So the same user-supplied text that a chat message would have had screened reached the image / speech / translation providers unscreened.

Decision 

InputGuardrailScreener gains screenText(string): string — the single- string sibling of screen(array $messages) — sharing one private screenContent() loop so both apply identical guardrails and verdict handling.

AbstractSpecializedService takes the InputGuardrailScreener as a required dependency (after pipeline) and exposes `protected screenPrompt(string): string`. Each service screens its user-supplied prompt on the send path, before the request payload is built:

  • DALL·Egenerate, generateMultiple (DALL·E 2 branch; the DALL·E 3 branch screens via its delegation to generate()), edit.
  • FALgenerate, generateMultiple.
  • TTSsynthesize (synthesizeToFile / synthesizeLong delegate to it, so every chunk is screened).
  • DeepLtranslate, translateBatch (each element).

Whisper is out of scope: its payload is audio, not a prompt. The optional transcription-hint field is not screened here — if that becomes a concern it is its own step.

Consequences 

  • Breaking: AbstractSpecializedService gained a required InputGuardrailScreener constructor parameter (after pipeline, before the optional repositories). A subclass or manual construction must pass it; an InputGuardrailScreener([]) with no guardrails is a valid pass-through for tests that do not exercise screening. Required, not optional, for the same reason as the budget gate (ADR-078): a secret / injection screener that silently disappears when unwired is fail-open on exactly the control it provides.
  • A specialized prompt is now screened identically to a chat prompt: a REDACT verdict rewrites the text that is sent (and, where the service echoes the prompt back in its result, what it echoes), and a DENY / REQUIRE_APPROVAL throws the same typed exception before any spend — every service screens the prompt before its budget pre-flight, so a denied prompt costs nothing: no budget-aggregation queries, no dispatch. A REDACT verdict that carries no replacement text fails closed (throws) rather than passing the original through, on the specialized and the chat path alike.
  • Production wiring is unchanged: DI autowires the concrete InputGuardrailScreener into every specialized service. With no input guardrails tagged, screenText() is a pass-through and behaviour is identical to before.
  • Still deferred: the fail-closed dispatch seam (getSecureClient() throwing when no lifecycle context is active); and folding the per-service usage recording into a tagged pipeline extractor.

ADR-099: Fail-closed HTTP egress for the specialized services 

Status

Accepted

Date

2026-07-21

Authors

Netresearch DTT GmbH

Context 

ADR-097 routed every specialized dispatch through the pipeline via runLifecycle(), so an image / speech / translation call now gets the same telemetry row, correlation id and circuit breaker as a chat call. But the wrapping is a convention: a new service method — or a refactor — that builds a request and sends it without calling runLifecycle() would compile, pass, and silently spend against the provider with no telemetry, no circuit breaker and no usage. Nothing enforced the convention.

Decision 

The HTTP egress fails closed. AbstractSpecializedService tracks a private $withinLifecycle flag, set only while a runLifecycle() dispatch is executing (saved/restored around the terminal, so nesting is safe). assertWithinLifecycle() throws a \LogicException when the flag is false, and it is called at every HTTP-egress point — executeRequest() and the two binary/multipart senders in TextToSpeechService and WhisperTranscriptionServicebefore their try block, so the guard is not swallowed by the Throwable -> ServiceUnavailableException mapping.

A \LogicException (not a service exception) is deliberate: dispatching outside a lifecycle is a programmer error, not a runtime fault, and must not be retried or mapped to a transient failure.

Enabling the guard required routing the last unwrapped provider calls through runLifecycle(). DeepL's detectLanguage() is a billable translate call and now runs as ProviderOperation::Translation — closing a real telemetry gap. Its getUsage() and getGlossaries() are free metadata lookups; they run as the new ProviderOperation::Metadata so they are observable and circuit-breaker guarded like any provider HTTP call, but labelled honestly rather than as a translation.

Consequences 

  • A specialized service method that reaches the provider without runLifecycle() now throws immediately instead of spending unobserved. The guard is the single invariant behind ADR-097's promise.
  • ProviderOperation gained a Metadata case for provider status / metadata calls that are not themselves an AI generation.
  • DeepL detectLanguage / getUsage / getGlossaries now emit a telemetry row and are subject to the provider circuit breaker. They record no usage and enforce no budget (they are not billable generations, apart from the translate call detectLanguage already made), so cost accounting is unchanged.
  • Test doubles that exercise the low-level HTTP helpers must dispatch inside a lifecycle — the TestableSpecializedService delegate wraps its call in runLifecycle(), mirroring production. A deliberately unwrapped delegate asserts the guard throws.
  • Still deferred: folding the per-service usage recording into a tagged pipeline extractor read by the usage middleware.

ADR-100: Specialized usage recorded by tagged extractors in the pipeline 

Status

Accepted

Date

2026-07-21

Authors

Netresearch DTT GmbH

Context 

ADR-097 routed the specialized dispatches through the pipeline, but each service still recorded its own usage by calling UsageTrackerServiceInterface::trackUsage() directly after the dispatch — a second write path alongside UsageMiddleware, which records the token-shaped chat / embedding / vision responses. The specialized responses are not token-shaped (they measure images, characters, audio seconds), so UsageMiddleware skipped them and the service filled the gap itself. Two recorders, two places to keep the request-count and attribution rules consistent.

Decision 

A specialized service no longer writes usage. Before dispatch it attaches a SpecializedUsageIntent — the stable, dispatch-independent inputs it knows (model, resolved model / configuration uid, attribution uid, and the input- derived counters: characters, size, quality, batch size) — to the call-context metadata. A tagged UsageMetricsExtractorInterface (one per service, matched on operation and provider so DALL·E and FAL do not collide) reads that intent together with the raw response and returns a ProviderUsageRecord. UsageMiddleware writes it, as the single recorder, after the token path finds nothing.

The response supplies what the service could not know up front: DALL·E's gpt-image token object and the number of images returned, Whisper's audio duration (verbose_json only). Cost is computed in the extractor from the SpecializedCostCalculator exactly as the service did.

A service records usage iff it set an intent. DeepL's language-detection sub-call and the getUsage() / getGlossaries() metadata calls (ADR-099) set none, so the extractor returns null and nothing is recorded — the former double-count guard is now structural.

Consequences 

  • One write path for every AI call: UsageMiddleware records both the token- shaped responses and the specialized operations. The services drop their direct trackUsage() calls (and DallEImageService::trackImageUsage() / WhisperTranscriptionService::trackTranscriptionUsage() are gone).
  • UsageMiddleware gained an autowired iterator of extractors; with none tagged its behaviour is unchanged. ProviderOperation::Metadata records nothing (no extractor claims it).
  • The recorded rows are unchanged — same service type, provider, metrics, cost, model / configuration uid and attribution — verified end-to-end: the service tests now drive a real UsageMiddleware + the service's extractor and assert the same rows they asserted before.
  • Adding a specialized provider means adding one extractor tagged nr_llm.usage_metrics_extractor and setting an intent before dispatch; no service touches the usage table.

ADR-101: AgentRuntime — the agent-run lifecycle as a public service 

Status

Accepted

Date

2026-07-21

Authors

Netresearch DTT GmbH

Context 

Persistence (ADR-081), human-in-the-loop approval (ADR-084) and termination semantics (ADR-092) work, but they were assembled inside ToolPlaygroundController: it began the run, built the trace, persisted events, caught the suspension, settled the status and executed the resume. The lifecycle ladder — runLoop → catch suspension → catch guardrail → catch failure → settle completed — was copied three times (batch, resume, stream), and any new consumer (a scheduler task, a queue worker, an editor action) would have had to re-assemble it again, each copy a chance to get the fail-closed rules wrong.

Decision 

AgentRuntimeInterface (Classes/Service/Agent/) is the public application service for agent runs; AgentRuntime implements it and owns the ladder exactly once. The playground controller is now a UI adapter: it parses the request into an AgentRunRequest / ApprovalDecision and maps the returned AgentRunResult onto its JSON / NDJSON shapes, which are unchanged.

interface AgentRuntimeInterface
{
    public function run(AgentRunRequest $request, ?Closure $onStep = null): AgentRunResult;
    public function approve(string $runUuid, ApprovalDecision $decision, ?Closure $onStep = null): AgentRunResult;
    public function cancel(string $runUuid): bool;
    /** @return list<AgentRunEvent> */
    public function events(string $runUuid, int $afterSequence = -1): array;
    public function status(string $runUuid): ?AgentRun;
}
Copied!
  • run() / approve() never throw for a run outcome — the result is already settled, discriminated by AgentRunOutcome (COMPLETED / AWAITING_APPROVAL / SUSPEND_FAILED / GUARDRAIL_BLOCKED / GUARDRAIL_APPROVAL_REQUIRED / FAILED). approve() throws typed AgentRuntimeExceptions only for an invalid request, before any execution: not-awaiting-approval, configuration gone, corrupt state, event position unavailable, claim lost.
  • onStep is a live observer fired for each recorded step before it is persisted (preserving the streaming path's emit-before-persist order); the NDJSON stream is built on it.
  • The roadmap sketched start(request): handle + run(handle). That split is only real once the request payload is persisted so a different process can execute it — the queue epic. Until then a handle would not be rehydratable, so run() is synchronous begin-and-execute; AgentRunRequest is a plain value object so the queue epic can serialise it and add asynchronous execution without changing these signatures.

Hardening folded in 

  • Suspension-safe finally-guard. The abandoned-run settle (previously stream-only) now guards every path, and every branch — including a successful suspension — marks the run settled first. An unguarded finally-settle would flip a just-suspended run (WAITING_FOR_APPROVAL is non-terminal) to FAILED and destroy its resumable state.
  • Fail-closed re-suspension. The old resume path silently ignored a failed re-suspension and still answered awaiting_approval — an unresumable promise. The unified ladder applies ADR-092's fail-closed rule everywhere; the distinct SUSPEND_FAILED outcome makes it visible.
  • Iteration ceiling. The per-run round cap (20) is a runtime invariant (AgentRuntime::MAX_ITERATIONS), no longer a controller constant. Only an explicit maxIterations is clamped; null keeps the loop's own lower default.
  • Deterministic event sequence. A resume continues the stream at MAX(sequence) + 1 (was: a count that silently restarted at 0 on a query failure, interleaving segments). The position is probed before the claim — a failure refuses the resume while the run is still suspended, so the approval can simply be retried — and resolved again after winning it, so a request that stalled across another approval's continuation can never write duplicate sequences. (The narrower pre-existing window that such a stale request resumes the earlier suspension's state is unchanged from the previous controller code and bounded by the claim fence.)
  • Cancel is a real fence. suspendRun is now a guarded transition (suspends only a run still RUNNING). Previously an in-flight loop's late suspension could resurrect a just-CANCELLED row to WAITING_FOR_APPROVAL, offering an approval flow — and an executable gated tool — for a run the operator was told was stopped. A refused suspension takes the fail-closed SUSPEND_FAILED path; the terminal-status guard then discards its settle, so the run stays CANCELLED.
  • No fail-open unpersisted suspension. A run whose persistence was down at begin (null handle) that then hits an approval-gated tool is SUSPEND_FAILED, not AWAITING_APPROVAL: nothing was stored, so approve('') could only ever fail — the old code announced an approval flow that did not exist.
  • Single-sourced logging. The runtime logs each failure / guardrail block / refused suspension once, with the run uuid; the controller no longer re-logs the same event (the old per-channel messages, including "Tool playground resume failed", are replaced by the runtime's).
  • Audited decisions. The operator's decision is persisted as a new AgentEventKind::APPROVAL event (payload {approved, decidedBy}, best-effort like every event write). fromRunStepKind() deliberately does not resolve it — it is not a RunStep kind; stored kinds hydrate via tryFrom(). No free-text note: the event stream is privacy-filtered (ADR-064) and prose would bypass that.
  • Privacy-safe status. status() strips the suspended-state transcript: it is stored verbatim for resume and bypasses the privacy filter, so it is not part of the status surface.

Consequences 

  • AgentRuntimeInterface is a public DI alias (Category B), raising the audited public-service count from 34 to 35 (later raised to 36 by ADR-106, which makes GuardrailRegistry public for its TCA itemsProcFunc). This ADR supersedes ADR-094 as the count authority. It is a consumer interface: call it, do not implement or decorate it outside nr_llm — methods and AgentRunOutcome cases may be added in minor releases (the queue epic will), so exhaustive matches need a default arm. ToolLoopServiceInterface stays public for consumers that want the bare loop; the runtime is the preferred surface.
  • nrllm:agent:cancel goes through the runtime; the playground controller lost its ToolLoopService / AgentRunPersister dependencies.
  • Breaking: AgentRunPersister::resumeHandle() now returns ?AgentRunHandle (null = refuse the resume), and AgentRunRepositoryInterface gained maxEventSequence().
  • Now possible without touching the playground: scheduler and messenger workers, consumer extensions, batch runs, review queues, editor actions and status polling (events() pages by sequence) — the run lifecycle they get is the tested one.

ADR-102: Queued agent runs over the TYPO3 message bus 

Status

Accepted

Date

2026-07-21

Authors

Netresearch DTT GmbH

Context 

AgentRunStatus::QUEUED has been reserved vocabulary since ADR-081, and ADR-101 deliberately deferred asynchronous execution: a start()/run() split is only real once the request payload is persisted so a different process can execute it. Every consumer that wants batch runs, review queues or scheduled agent work needs exactly that — enqueue now, execute in a worker, poll status.

TYPO3 core (v13.4 and v14.3 identically) ships Symfony Messenger as the message bus: MessageBusInterface is autowireable, handlers register via #[AsMessageHandler], the default routing sends every message to the synchronous transport, and an installation opts into asynchronous execution by routing messages to the core-provided doctrine transport and running bin/typo3 messenger:consume. No composer change is needed.

Decision 

AgentRuntimeInterface (consumer contract: methods may be added in minor releases, ADR-101) gains the queued half of its lifecycle:

public function enqueue(AgentRunRequest $request): string;                 // returns run uuid
public function runQueued(string $runUuid, ?Closure $onStep = null): ?AgentRunResult;
Copied!

The run row is the state; the message is only a wake-up call. enqueue() serialises the request into a new queued_request column on a QUEUED tx_nrllm_agentrun row (entities travel as uids and are re-loaded at execution time — the same identity-over-snapshot choice approve() makes; messages and options use their established array forms, the ADR-084 SuspendedRunState precedent). Two round-trip details are load-bearing: plannedCost and the idempotency key are deliberately excluded from ToolOptions::toArray() (sound for the ADR-084 resume, whose pre-flight already ran) but a queued run's budget pre-flight has not happened yet, so they travel out-of-band in the payload and are re-injected at rehydration — run() and enqueue() of the same request hit the identical budget gate and dedup. And a null RunAugmentation stays null: fabricating an empty one would flip the loop into its prompt-baking assembly branch and silently change the prompt composition versus the identical direct run. enqueue() then dispatches an AgentRunQueuedMessage carrying nothing but the uuid. AgentRunQueuedHandler (#[AsMessageHandler]) calls runQueued(), which atomically claims the row — a guarded QUEUED → RUNNING UPDATE in the claimForResume() idiom, stamping started_at plus a worker lease (claimed_by, lease_expires) — rehydrates the request and drives the identical fail-closed ladder as run().

Consequences of that split:

  • Duplicate or stale messages are harmless — the claim decides; the loser sees null and the handler treats it as a non-event (no redelivery).
  • Cancel-while-queued works with zero new code: the guarded terminal transition already covers QUEUED, and a cancelled run is unclaimable.
  • Fail-closed enqueue: a row that cannot be stored throws RunEnqueueFailedException; a dispatch failure settles the just-stored row FAILED first — no orphaned QUEUED run that no message will ever wake.
  • Fail-closed execution: the claim comes first; a rehydration failure (corrupt payload, configuration deleted while queued) settles the claimed run FAILED instead of stranding it. A skill or snippet deleted while queued is simply no longer forced — live resolution, like the interactive path.
  • run() and runQueued() share one execution path, so the iteration ceiling, the trace wiring and the outcome taxonomy cannot drift.
  • The handler never throws for run outcomes, so under messenger:consume a failed run is a handled message (the row carries the outcome) — messenger retry/dead-letter machinery, which TYPO3 wires none of, is not relied upon.

Operations 

Default (no configuration): the SyncTransport executes the run in-process during enqueue() — semantically identical to run(), just addressed by uuid. For genuinely asynchronous execution the installation routes the message to the doctrine transport and runs a consumer:

// settings.php / additional.php
$GLOBALS['TYPO3_CONF_VARS']['SYS']['messenger']['routing']
    [\Netresearch\NrLlm\Service\Agent\Queue\AgentRunQueuedMessage::class] = 'doctrine';
Copied!
bin/typo3 messenger:consume doctrine
Copied!

Schema: queued_request mediumtext (cleared by the guarded terminal settle, like suspended_state — and stripped from status() for the same ADR-064 privacy reason), claimed_by varchar(64) and lease_expires int with fail-safe ''/0 defaults. The lease (15 min) is written at claim time and is diagnostic for now — the stale-run reaper, heartbeat, per-failure-class retry and WAITING_FOR_INPUT are the remaining slices of the queue epic, each its own step on top of this substrate.

Consequences 

  • Batch runs, review queues, scheduled agent work and editor actions can enqueue through the public runtime and poll via status() / events() — no playground involvement, no bespoke lifecycle.
  • AgentRunRepositoryInterface gained enqueueRun() and claimQueued() (breaking for out-of-tree implementors — the interface is in-repo plus test doubles by design).
  • AgentRuntime gained optional MessageBusInterface / SkillRepository / PromptSnippetRepository dependencies (autowired in production); with no bus wired, enqueue() fails closed.
  • A run FAILED by a dispatch or rehydration failure records PROVIDER_FAILED as its termination reason — acceptable coarseness until the retry epic introduces per-failure-class classification at the run level (ADR-095 groundwork exists).

ADR-103: Cooperative cancellation at step boundaries 

Status

Accepted

Date

2026-07-21

Authors

Netresearch DTT GmbH

Context 

Cancellation has been a persistence-level fence since ADR-092: the guarded terminal transition wins the row and a late settle is discarded — but the in-flight loop itself kept running to completion, spending provider calls and executing tools whose outcome was then thrown away. With queued runs (ADR-102) that gap grows: a worker run can be long, and nrllm:agent:cancel is the only brake an operator has. The P1 roadmap asks for exactly this: check the cancel flag between model and tool steps.

Decision 

The run executor's trace hook gains a cancellation probe. Every step boundary — after a provider response, after each tool execution, before the next round — already records a step through the runtime's onRecord closure; the probe re-reads the run row there and, when it is CANCELLED, throws the internal RunCancellationRequestedException. The ladder catches it as control flow (before the generic Throwable), attempts no settle (the cancel already won the terminal transition; a late settle would be discarded anyway) and returns the new AgentRunOutcome::CANCELLED.

Properties:

  • The loop stays persistence-unaware (ADR-081): the probe lives entirely in the runtime's trace closure; ToolLoopServiceInterface is untouched.
  • A step in flight runs to its boundary — cancellation is a cooperative check, not a signal. The boundary step itself is still emitted and persisted, so the audit stream is complete up to the abort point.
  • No further spend after the boundary: the next provider call and the pending tool executions of the following round never start.
  • Fail-soft probe: the row read goes through the fail-soft persister — a store hiccup yields null, never a fabricated cancellation. One indexed row read per step; steps are provider-call-slow, so the cost is noise.
  • Works identically for interactive (run()), resumed (approve()) and queued (runQueued()) segments — they share the one trace builder.

Consequences 

  • AgentRunOutcome gained CANCELLED (the documented minor-release growth path; consumers match with a default arm). The playground maps it to a status: 'cancelled' payload / cancelled stream event — a decision, not an error.
  • AgentRuntimeInterface::cancel()'s contract is upgraded from "fence only" to "fence + cooperative stop at the next step boundary".
  • A cancelled run's in-flight segment now ends within one step instead of running to completion — the remaining latency is bounded by the longest single step (one provider call or one tool execution), which the future heartbeat/lease epic can further constrain.

ADR-104: Worker heartbeat, stale-run reaper, retry and dead-letter 

Status

Accepted

Date

2026-07-21

Authors

Netresearch DTT GmbH

Context 

Queued runs (ADR-102) claim a row with a lease (claimed_by + lease_expires), but until now nothing renewed or acted on it — the lease was diagnostic only. Two gaps remained:

  • A dead worker strands its run forever. If the PHP process is killed, the container recycled or the machine rebooted mid-loop, the run stays RUNNING with a lease nobody renews. No other worker can take it (the claim is optimistic on QUEUED), so it never finishes.
  • A transient provider failure is terminal. A queued run that hit a 5xx, a rate limit or an exhausted fallback chain settled FAILED immediately — no retry, even though a different worker moments later might have succeeded.

The P1 roadmap asks for a heartbeat, a stale-run reaper, retry-per-failure-class and a dead-letter terminus.

Decision 

Heartbeat. The runtime's trace hook (the same step-boundary closure the cancellation probe uses, ADR-103) renews the lease on every step for a worker run — an ownership-guarded UPDATE (WHERE status='running' AND claimed_by = :me). A renewal that affects no row means the worker lost the run (reaped, re-claimed or terminated); it throws the internal RunLeaseLostException, caught by a dedicated ladder arm that stops without settling (the row belongs to its new owner now) and returns AgentRunOutcome::LEASE_LOST. Interactive run()/approve() segments hold no lease and never renew — the heartbeat is worker-only. To avoid a zombie worker appending an event whose sequence collides with the new owner's stream, the boundary step is persisted after the renewal check, not before.

Reaper. nrllm:agent:reap (schedulable) finds RUNNING runs whose lease has expired (lease_expires > 0 AND lease_expires < now — the > 0 excludes interactive runs) and either requeues them (budget permitting) or dead-letters them. Both mutations re-check staleness inside the UPDATE, so a heartbeat renewal that lands between the reaper's SELECT and its write wins and the merely slow (not dead) worker is left alone.

Retry. A queued run's failure runs through a recovery hook in the ladder, before the default settle. The failure is classified through the existing FailureClassifier (extended so a FallbackChainExhausted wrapper classifies by its most recent attempt rather than as UNKNOWN):

  • Not retryable (auth, configuration, 4xx) → dead-letter now with reason NOT_RETRYABLE.
  • Retryable but the requeue budget is spent → dead-letter with RETRIES_EXHAUSTED.
  • Retryable and under budget → ownership-guarded requeue (bumping requeue_count) and a re-dispatch with an exponential DelayStamp backoff, returning AgentRunOutcome::REQUEUED.

Interactive runs pass no recovery hook and surface failures unchanged.

Budget. A new requeue_count column, shared by both requeue sources (failure retry and stale reclaim), capped by AgentRuntime::MAX_REQUEUES (3), so a deterministically crashing or hanging run cannot loop forever.

Dead-letter = FAILED + reason axis. No new status is introduced (the status model stays stable, and purge/retention are untouched). Dead-lettering is expressed on the ADR-092 reason axis via the two new non-retryable reasons above.

Consequences 

  • AgentRunOutcome gained REQUEUED and LEASE_LOST; AgentRunTerminationReason gained RETRIES_EXHAUSTED and NOT_RETRYABLE (both isRetryable() === false). All are the documented minor-release growth path — consumers match with a default arm, and the playground never sees the new outcomes (they arise only on the worker path).
  • tx_nrllm_agentrun gained requeue_count.
  • Real exponential backoff requires the doctrine transport (which honours the DelayStamp); the default SyncTransport ignores the delay and retries in-process, bounded by MAX_REQUEUES — the same requirement ADR-102 already places on async execution.
  • A worker that renews its lease adds one guarded UPDATE per step boundary (alongside the ADR-103 read); steps are provider-call-slow, so the cost is noise.
  • The reaper only reclaims abandoned queue workers. An interactive run abandoned by a dying client keeps no lease and is still reaped by the age-based retention path (nrllm:privacy:purge).

ADR-105: Typed user-input suspension (WAITING_FOR_INPUT) 

Status

Accepted (the approval+input registration ban is widened by ADR-134: A builtin's declared write effect implies human approval)

Amended

2026-08-09 by ADR-134

Date

2026-07-22

Authors

Netresearch DTT GmbH

Context 

Human-in-the-loop so far means approval (ADR-084): a tool opts into a verdict, the run suspends WAITING_FOR_APPROVAL, and approve() continues it with approve/deny. But some tools need the human to supply typed data, not a verdict — a target the model cannot know, a value only a person can decide. The P1 roadmap's final queue-epic slice asks for exactly this: a tool that suspends the run to collect schema-validated input and resumes with it fed back into the tool's arguments.

Decision 

A new suspend kind, routed by the persisted status discriminator: WAITING_FOR_APPROVAL → approve(), WAITING_FOR_INPUT → ``submitInput()``. The two never share a code path after the row is loaded, so the persisted state needs no "kind" field.

  • Tool signal. A tool opts in with the RequiresInputInterface marker (the sibling of RequiresApprovalInterface), declaring an input schema via getInputSchema(). When the model calls an offered such tool, the loop throws ToolInputRequiredException carrying a SuspendedRunState whose new inputToolName/inputSchema fields name the target and its schema. The runtime's ladder catches it right after the approval arm (before the guardrail pair and the generic Throwable) and persists WAITING_FOR_INPUT.
  • Reuse. The status is stored in the existing suspended_state column (no new column), the guarded suspend/claim transitions mirror ADR-084 (suspendRunForInput / claimForResumeFromInput), the lease is cleared so the reaper (ADR-104) ignores a waiting run, and the MAX(sequence)+1 resume-position resolution is unchanged.
  • Divergence — validate before claim. submitInput() validates the submission against the declared schema (via the existing structure-only JsonSchemaValidator, ADR-082) before probing or claiming the run. An invalid submission is rejected without consuming the claim, so the run stays WAITING_FOR_INPUT and the user can resubmit — a flow approve/deny has no analogue for.
  • Overlay. On resume, the human's validated values are overlaid onto the target call's arguments bounded to the schema-declared keys: the model's own values for those keys are stripped and only declared keys from the human are merged, so neither side can smuggle a value into the other's field.

Fail-closed rules 

  • A degenerate schema is corruption, never accept-all. An empty/shapeless inputSchema (against which validate() returns true for anything) is rejected at both the capture-time gate (LogicException) and the rehydrate gate (CorruptSuspendedStateException) — one shared predicate (InputSchema::isUsable()) is the single authority.
  • A tool may not be both approval- and input-gated. The approval-resume path carries no input and would silently drop the mandatory data; the combination is rejected at tool registration, and — defence in depth — resume() refuses an input-requiring pending call rather than fail-open executing it.

    ADR-134: A builtin's declared write effect implies human approval widened the ban without changing this reasoning: a declared write effect became a second way to be approval-bound, so a non-remote, write-declaring tool may not implement RequiresInputInterface either. The resume() refusal named above is what makes that combination permanently unexecutable, not what handles it. Read ADR-134 for the ban in force.

  • An unstorable suspension fails closed as SUSPEND_FAILED (as approval does): promising an input flow that cannot be resumed would strand the client.
  • Submitted values are untrusted content entering the model context; the submit entry point is admin-gated, which is the injection mitigation (structure-only validation does not sanitise content).

Consequences 

  • AgentRunOutcome gained AWAITING_INPUT; AgentEventKind gained INPUT (payload {submittedBy} only, never the values, ADR-064); AgentRunStatus::WAITING_FOR_INPUT already existed and needed no change. All are the documented minor-release growth path — consumers match with a default arm.
  • AgentRuntimeInterface gained submitInput() (an interface method, not a new public service — the audited public-service count is unchanged); AgentRunRepositoryInterface gained suspendRunForInput() and claimForResumeFromInput(); ToolLoopServiceInterface gained resumeWithInput().
  • Accepted limitations: validation is structure-only (no min/max/enum/pattern — a tool needing those re-checks in execute(), as completeStructured does); a single turn requesting two tool inputs is fail-closed-refused on the second, not collected; the schema is delivered in the suspend response, not via status() (which strips suspended_state), so a client reloading mid-wait loses the form — matching the existing approval pendingTools limitation.

ADR-106: Per-configuration guardrail policies 

Status

Accepted

Date

2026-07-22

Authors

Netresearch DTT GmbH

Context 

Guardrails (ADR-085 output, ADR-087 input, ADR-088 streaming) are applied GLOBALLY: every tagged guardrail runs on every provider response. Different use-cases need different policies — a public-facing configuration wants the provider content filter, an internal tooling configuration may not — but there was no way to vary the set per configuration. The P1 roadmap asks for exactly that, without ever letting a configuration weaken the security-critical guardrails.

Decision 

A configuration selects which OPTIONAL guardrails apply; MANDATORY guardrails (secret redaction) always run and are never selectable.

  • Identity + classification. A shared GuardrailIdentity interface (parent of both GuardrailInterface and InputGuardrailInterface) adds getIdentifier() (a stable slug, SHARED across the input and output sides — the two secret-redaction classes report the same secret-redaction) and isMandatory() (the per-class authoring signal). An abstract method, not a marker, so every implementer makes a PHPStan-checked conscious choice — a forgotten marker would default insecure.
  • Identifier-level authority, fail-closed. GuardrailRegistry collects both tagged iterators and computes mandatory-ness PER IDENTIFIER. Any side mandatory ⇒ the identifier is mandatory; a cross-side disagreement (mandatory on one side, optional on the other) THROWS at build — a copy-paste error that flips one secret-redaction class to optional fails the container rather than shipping a one-sided leak. The GuardrailPolicyResolver reads the registry's verdict, never a raw per-instance flag.
  • The filter. GuardrailPolicyResolver::filter() drops a guardrail only when the configuration has a NON-EMPTY selection AND the identifier is not registry-mandatory AND not in the selection. A null configuration or an empty selection runs everything (unchanged from before this ADR); a mandatory guardrail is kept against ANY selection value (empty, partial, unknown, or all-unknown). One filter, applied at all three points: GuardrailMiddleware (output), InputGuardrailScreener (input), StreamingDispatcher (live redaction + end-of-stream audit) — each reading the configuration already in scope (ProviderCallContext / the streamed configuration), no re-plumbing.
  • Storage. A allowed_guardrails CSV column on tx_nrllm_configuration (mirroring allowed_tool_groups) with a selectCheckBox TCA field whose items are DISCOVERED from GuardrailRegistry::selectableIdentifiers() (optional-only; mandatory guardrails are never listed). The migration is additive, defaulted NOT NULL DEFAULT '' — existing rows read '' = run all, byte-identical to today.

Fail-closed rules 

  • A degenerate/empty schema of selectable ids never means "run nothing": the mandatory floor is kept unconditionally, and an all-unknown selection keeps exactly the mandatory set.
  • A guardrail identifier is IMMUTABLE API — a rename silently drops the guardrail for configurations that stored the old value (the mandatory floor is unaffected; opted-in optional protections would silently stop). Introduce a new guardrail instead; a genuine rename requires an Install Tool UpgradeWizard that rewrites stored allowed_guardrails CSVs.

Consequences 

  • BREAKING API change. GuardrailInterface / InputGuardrailInterface are documented public extension points (the nr_llm.guardrail / nr_llm.input_guardrail tags). Adding getIdentifier() + isMandatory() fatals an out-of-tree implementer at container compile. See Upgrading.
  • GuardrailRegistry is public (Category E, for the TCA itemsProcFunc), raising the audited public-service count 35 → 36 (Adr101 remains the count authority and is updated in the same change).
  • Content filter is optional. provider-content-filter enforces the provider's own policy block (finishReason=content_filter → DENY), not secret leakage, so a configuration may select it out; secret redaction stays mandatory. Flip its isMandatory() to true if a deployment treats suppressing a provider safety block as a security concern — the registry then makes the identifier mandatory and drops it from the picker automatically.
  • Input axis is inert today. The only input guardrail is the mandatory secret redaction, so per-config input filtering changes nothing yet. The InputGuardrailScreener accepts the configuration and applies the same filter; LlmServiceManager passes no configuration for now. When an OPTIONAL input guardrail is added, thread the configuration at the config-bound entrypoints (a localised follow-up).
  • Eval unaffected. allowed_guardrails is a new column defaulting '', so every existing configuration (including eval targets) runs all guardrails exactly as before.

Upgrading 

A third-party guardrail implementing GuardrailInterface or InputGuardrailInterface must add getIdentifier() (a stable kebab-case slug) and isMandatory() (true only for a security-critical always-on guardrail). Input and output classes sharing a concept MUST return the same identifier and the same isMandatory() value, or the container fails closed.

ADR-107: Agent-loop context-window management 

Status

Accepted (which model's window, and which paths bind one — see ADR-143)

Date

2026-07-22

Amended

2026-08-10 by ADR-143

Authors

Netresearch DTT GmbH

Context 

The tool loop (ADR-081 ff.) appends each assistant tool-call turn and its tool_result messages to one transcript that is re-sent on every iteration. Nothing bounded that transcript against the model's context window (Model::getContextLength()), so a long agentic run — many tool calls, large tool outputs — eventually overflowed the window and failed at the provider with a raw 4xx that FailureClassifier maps to a generic CLIENT_ERROR, indistinguishable from an auth or config error.

Decision 

An optional ContextWindowManager collaborator on ToolLoopService bounds the transcript before each provider send. Absent it (the lean test wiring) the loop sends the full transcript exactly as before — every enforcement site is a no-op.

  • Turn-atomic pruning (the correctness crux). Pruning drops oldest WHOLE turns — an assistant tool-call message together with ALL its tool_result replies — so the tool-call/tool-result pairing the provider requires is never broken. The head (the leading system run plus everything up to and including the first user message) and the newest turn are never dropped, so the output is a structurally valid, non-empty transcript that still carries the task and the most recent context. A cheap post-fit pairing guard defers to the provider rather than ever emit a known-orphaned request. Summarization was rejected: it needs an extra provider call per prune (cost, latency, non-determinism through SuspendedRunState); dropping is deterministic, cheap and provably safe.
  • Over-counting estimator. No BPE tokenizer (no runtime dependency): a content-class-aware chars/N — prose divides by 3.5, DENSE segments (tool JSON arguments, tool_result payloads, the tool-schema block) by 2.5, plus per-message and per-tool-call overhead. A calibration factor seeded above 1.0 scales the estimate and only ever grows toward the real prompt-token counts each provider call reports, so the estimate errs high throughout and never under-prunes into an overflow. The manager is stateful per run and self-resets on each loop's first send (a null lastUsage), so a single shared ToolLoopService never carries one run's calibration into the next.
  • Reserve + graceful failure. `budget = contextLength - reserve - safety``, where the reserve is the response allocation (options ``max_tokens`, else the model output cap, else a proportional floor) and an unknown context length falls back to a conservative 8192. When even the pruned floor still exceeds the budget, no provider call is made: a ContextTruncatedException stops the loop on the new AgentRunTerminationReason::CONTEXT_TRUNCATED (non-retryable) — a legible terminus instead of a misclassified provider 4xx.

Consequences 

  • AgentRunTerminationReason gained CONTEXT_TRUNCATED (isRetryable() === false) — the documented minor-release growth path.
  • Global default only, no per-configuration knob (YAGNI); maxTokensPerDay / modelSelectionMode are the storage precedent if an override is ever needed.
  • Enforcement covers the three real provider-send sites in runLoop (the in-loop tool send, the no-tools plain completion, the cap-hit synthesis); every send goes through one of them, so no separate pre-loop assembly pass is needed. The plain-completion sites pass no tool schemas, so phantom schema bytes never inflate the estimate of a payload that will not carry them.
  • Streaming is out of scope. StreamingDispatcher runs a separate single-shot pipeline that never calls runLoop(); bounding a streamed request against the window (and reconciling its own chars/4 heuristic) is a follow-up.
  • Observability: a pruning event is logged at info level; a dedicated inspector RunStep for the trace is a follow-up. The CONTEXT_TRUNCATED reason is the load-bearing operator signal and is on the result.

ADR-108: Typed ToolResult with run-only artifacts 

Status

Accepted

Date

2026-07-22

Authors

Netresearch DTT GmbH

Context 

ToolInterface::execute() returned a plain string that ToolLoopService fed straight back to the provider AND rendered into the backend Tool Playground inspector. A tool that computes something inherently structured — a set of records, a schema, a file listing — could only flatten it to text, so the inspector had nothing richer to show than the same line-based blob the model receives.

Adding structured output naively is a security problem: a tool result already egresses to an external LLM provider, so any structured payload bolted onto the return value would ride the wire too, widening the egress surface for attacker-influenceable tool bytes (the model is steerable by injected skill prose, see ADR-064).

ADR-094 introduced ToolDataClassInterface as an opt-in marker deliberately kept OFF ToolInterface to avoid editing all 41 builtins at once. That precedent does not apply here: egress separation must BE the return value (a structured channel that is unreachable from the wire), not an annotation a tool may forget to add.

Decision 

Replace execute(array $arguments): string with execute(array $arguments): ToolResult — a final readonly value object carrying exactly one provider-facing string $content, a bool $isError, and a list<ToolArtifact> $artifacts that is run-scoped: it flows only to the trace, the inspector event stream and the persisted audit copy, and has NO code path to the provider wire.

Egress separation is enforced by construction:

  • ToolResult has no __toString() and no accessor that merges an artifact into a wire string; ->content is the single path to a wire string.
  • The sole wire sink, ChatMessage::toolResult(), is only ever handed $result->content.
  • ToolLoopService::invoke() — the single seam every executed call passes through — UTF-8-coerces and byte-bounds BOTH channels before any ToolResult leaves the process. content keeps its existing 50 000-byte cap (capResult()); artifacts get an independent 50 000-byte serialised budget (boundArtifacts()).

The private constructor forces the ToolResult::text() / ToolResult::error() factories; error() carries no artifacts, so a failing tool can never leak a half-built structure.

Artifact type model 

ArtifactType is the smallest closed set whose every case has a v1 emitter plus a fallback:

enum ArtifactType: string {
    case TABLE = 'table';   // {columns: list<string>, rows: list<list<string>>}
    case TEXT  = 'text';    // {text: string} — fallback + "artifacts omitted" marker
}
Copied!

This is a rendering shape, NOT a semantic taxonomy. TREE, LIST, KEY_VALUE, LINK and CODE are additive follow-ups — each lands later as one new enum case plus one JS branch, with no consumer or persisted-data migration. No case ships without a committed producer: TREE (a page-tree emitter) is intentionally deferred rather than shipped empty.

The sole v1 emitter is ReadRecordsTool, which builds its TABLE rows from the SAME already-redacted formatValue() cells its text lines use, in one pass — the artifact can never drift from, or re-expose more than, the text egress. The other 40 builtins ship text-parity via a mechanical ToolResult::text($string) wrap.

Fail-closed bounding 

boundArtifacts() UTF-8-coerces every string leaf, then validates the whole list with the EXACT flags the downstream sinks use (JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE) plus a depth-64 cap. Anything that survives therefore cannot throw at ToolPlaygroundController (streamLine() / respondJson()) or AgentRunPersister::recordStep() — crash-safety by construction, not by a lenient superset. On a JsonException (non-finite float, unencodable type, over-depth) or an over-budget encode, the WHOLE list is replaced by a single TEXT "Artifacts omitted" marker — never a mid-structure truncation.

Privacy 

toolArtifacts is added to RunStepPrivacyFilter's CONTENT_KEYS. Consequences flow from the existing machinery:

  • At the default METADATA/NONE level the artifact data is unset(); a summary (toolArtifactsCount + toolArtifactTypes) records shape and count but never bytes — mirroring toolResultLength.
  • At REDACTED the normalised list<{type,label,data}> is masked by the existing recursive redactor with no new code (the type discriminator and label are masked too; the JS renderer then falls to its unknown-shape fallback — fail-safe).
  • At FULL it is verbatim (deliberate).

As with toolResult (ADR-081), the LIVE NDJSON stream renders unfiltered from memory (RunStep::toArray()), so an admin's browser sees full artifacts even at METADATA while the persisted copy is summarised. This is intentional for the admin-only module, not a bypass.

Consequences 

  • ToolInterface is a breaking change across all 41 builtins. Pre-1.0 (ADR-090) this is acceptable and announced; third-party tools discovered via the nr_llm.tool tag must return a ToolResult (the ToolResult::text() factory keeps the trivial case a one-line change).
  • The provider wire is unchanged: ChatMessage::toolResult() still receives a string, so no provider adapter changes.
  • ToolInvocation and RunStep gain a typed artifact field (appended with a default, positions stable). ChatMessage, ToolLoopResult and SuspendedRunState are untouched — artifacts are audit/display state, never resumable functional state, so they must not ride the suspend payload.
  • The inspector gains a conditional "Artifacts" tab rendering TABLE / TEXT and an unknown-type JSON fallback, all via textContent (never innerHTML) because artifacts are attacker-influenceable.

See also ADR-010 (tool function-calling design), ADR-094 (tool data-class trust zones) and ADR-064 (event privacy).

ADR-109: Agent Runs approvals inbox (backend module) 

Status

Accepted (stale-review binding superseded by ADR-132)

Amended

2026-08-09 by ADR-132

Date

2026-07-22

Authors

Netresearch DTT GmbH

Context 

The human-in-the-loop suspension features had service plumbing but no operator UI. A run can suspend WAITING_FOR_APPROVAL (ADR-084) or WAITING_FOR_INPUT (ADR-105); AgentRuntime::approve() and submitInput() continue it. Until now the only caller was the admin-only Tool Playground developer tool — there was no first-class surface for an operator to find the runs that need a decision and act on them.

Decision 

Ship a new admin-only backend submodule nrllm_runs ("Agent Runs") — an approvals inbox focused on the runs that need a human, plus a read-only list of recent terminal runs for context.

Progressive enhancement, no AJAX 

The page works fully with JavaScript OFF: native <f:form> POST to module-route controllerActions, a POST-redirect-GET flush with session flash messages, native <details> for long tool arguments, and native form validation. The one JavaScript module is enhancement only — it moves focus to a 422 error summary reliably across browsers. There is no JSON/AjaxRoutes path and no content-negotiation dual-path — a deliberate simplification over the playground's batch-JSON contract.

Authorization is the module access => admin on all three actions (list / approve / submitInput). A module-route action cannot be reached without it, so RequiresBackendAdminTrait (whose JSON 403 body would be wrong for an HTML page) is not used here. Any admin may act on any run; the recorded decidedBy / submittedBy uid is audit-only. The CSRF defence is the backend module route token the <f:form action=...> URL carries (validated by the RouteDispatcher), not __trustedProperties.

Four non-negotiable correctness/security properties 

  1. No-JS coercion. A native <form> posts every field as a string, but the validator is strict and submitInput() validates verbatim. A SchemaInputCoercer casts the POST to the schema's declared types AND omits empty OPTIONAL fields, so a blank optional integer/boolean does not 422 the whole submission; a non-numeric string for an integer is left uncoerced so the validator rejects it with a clear per-field error. A shared SchemaPropertyClassifier is the single type → control mapping used by both the widget factory and the coercer, so a rendered widget can never drift from its coercion.
  2. Never an empty form. InputSchema::isUsable() returns true for a scalar top-level schema like {"type":"string"}, which would render an empty, unsubmittable no-JS form. The input form renders ONLY for an object schema with at least one property; anything else is classified unreadable and shows a fail-closed notice, never an empty <form>.
  3. Stale-review binding. (superseded — see the ADR-132 paragraph below) approve() binds only to the run uuid and reloads whatever suspended state is CURRENT — a deny/continuation can re-suspend the SAME run with a NEW pending turn, so a stale tab (or a second admin) could authorize a call the operator never reviewed. The reviewed turn's digest (a SHA-256 of the pending calls) travels as a hidden field; the controller recomputes it from the freshly-loaded current state and refuses approve() on a mismatch, re-rendering "the pending action changed, please re-review". The narrow check→approve window is consciously accepted; eliminating it entirely would require a runtime change to ApprovalDecision and is out of scope.

    ADR-132 took that runtime change and closed the window. The digest still travels from the card, but the controller no longer verifies it: it hands the value to ApprovalDecision and the verification happens inside ResumeCoordinator::approve() against the state the resume CLAIM won. The controller-side check is gone — it read the row before the claim, so it could pass on a turn a concurrent approval had already replaced. A missing digest is refused exactly like a mismatching one, which also closes the Tool Playground endpoint that this ADR left unbound.

  4. Honest load errors. The two new persister queries return null strictly on a store error (an empty list only when there genuinely are none), so a DB hiccup shows a visible error infobox instead of a silently empty inbox that could hide waiting runs.

Accessibility (owner priority) 

WCAG 2.1 AA and full keyboard operability are first-class: h1 → h2 section landmarks → h3 per run; native <button type="submit"> in a labelled role="group"; native <details>; <fieldset>/<legend>/`<label for>`` with native ``required`` (preferred over ``aria-required`) and a visual *; aria-describedby to a per-field description; a focusable 422 error summary; the operator's raw input preserved across a 422 re-render; non-colour status. Tool arguments are Fluid-auto-escaped (never f:format.raw) — a stored-XSS guard, since they are model-chosen, attacker-influenceable text.

Privacy (ADR-064 reconciliation) 

The approval/input EVENTS record only who/when, never the values. But the operator must see the pending call arguments to decide, so the view factory reads the raw suspended_state directly (admin-only, display-only, escaped, never re-emitted into an event or log). TerminalRunView deliberately carries no suspended-state. This is a bounded, admin-only read, not an ADR-064 violation.

Consequences 

  • New: AgentRunController (3 actions), WaitingRunViewFactory + four view DTOs, SchemaInputCoercer + SchemaPropertyClassifier, BackendUserUidTrait (extracted from ToolPlaygroundController and shared), AgentRunStatus::awaitingValues(), two repository queries (findAwaiting / findRecentTerminal) and their fail-soft persister wrappers, Fluid templates/partials, the nrllm_runs module + icon + EN/DE XLIFF, and an enhancement JS module.
  • No change to AgentRuntime, InputSubmission or the DB schema/indexes — the existing status_lookup(status, crdate) index serves the inbox queries. ApprovalDecision was in this list too; ADR-132 gave it a third constructor parameter (?string $turnDigest) to carry the reviewed turn into the runtime (superseded).
  • Non-goals: no run detail/inspector page, no cancel control, no pagination/filter/search, no per-call approval verdicts (approval is turn-level), no nested-object input widget (rendered "unsupported"), no JSON path.

See also ADR-084 (approval suspension), ADR-105 (typed input suspension) and ADR-064 (event privacy).

ADR-110: Service account scopes 

Status

Accepted

Date

2026-07-23

Authors

Netresearch DTT GmbH

Context 

Every stateful entry point now carries an explicit AiActorContext (ADR-091) instead of reading $GLOBALS['BE_USER']. An interactive caller is a backend user, authorised by ownership and admin rights. A non-interactive caller — a CLI command, a scheduler task, a queue worker — has no backend user, so it identifies itself as a named service account.

Until now a service account was trusted for everything: mayAccessSession(), mayActOnRun() and the restricted-configuration gate (ConfigurationResolver) all returned true for any service account. That is too coarse. A narrow automation — say a nightly job that only cancels stale runs — would, the moment it holds a service-account context, also be able to approve pending runs, read any conversation, and use configurations restricted to other groups. A single over-broad principal is exactly the escalation surface the actor context was introduced to close.

Decision 

A service account carries an explicit, minimal set of scopes (NetresearchNrLlmDomainEnumServiceAccountScope). Each entry point a service account can reach checks the one scope it requires; a service account that does not declare that scope is denied. Backend users are unaffected — scopes govern service accounts only, and hasScope() is always false for an interactive caller, so an entry point must still combine it with its own ownership/admin check.

Fail-closed 

serviceAccount($name) with no scopes may do nothing. A capability is granted only by naming it: `serviceAccount('cli:nrllm:agent:cancel', [ServiceAccountScope::AGENT_CANCEL]). There is no wildcard scope, so a new or mis-declared automation can never acquire a capability it did not ask for, and a truncated or tampered serialised row drops any value that is not a known scope (:php:AiActorContext::fromArray()`).

One scope per enforcement point 

The taxonomy is deliberately small — every case maps to exactly one existing gate, so there are no unenforced scopes:

  • agent:approveAgentRuntime::approve() / submitInput()
  • agent:cancelAgentRuntime::cancel()
  • agent:readAgentRuntime::status() / events()
  • conversation:accessConversationService::send()
  • configuration:use — restricted-configuration gate

Run operations do not share one scope: an account granted agent:cancel can cancel but neither read nor approve. This is why mayActOnRun() takes the required ServiceAccountScope rather than deciding one blanket verdict for all five run methods.

Scopes round-trip with the actor 

The queue persists the full actor with a queued run (ADR-102) and rehydrates it in the worker. Scopes are part of that serialisation, so a service account that enqueues work resumes with exactly the capabilities it started with — never more.

Consequences 

  • The single shipped service account (the nrllm:agent:cancel CLI command) declares agent:cancel and nothing else.
  • enqueue() / run() are not gated by a scope: who may start a run is decided by whoever builds the request (a controller behind backend auth, a CLI behind shell access), not by a runtime scope check. A dedicated agent:run scope is deferred until a service-account caller actually reaches those methods, so no unenforced scope is shipped.
  • conversation:access is a single read-and-continue capability; a finer read-only vs write split is deferred until a consumer needs it.
  • New service-account callers must declare their scopes explicitly — a scopeless account failing closed is the intended behaviour, not a regression.

ADR-111: Tool side effects and fail-closed audit for writes 

Status

Accepted

Date

2026-07-23

Authors

Netresearch DTT GmbH

Context 

The agent queue is at-least-once (ADR-102). A worker renews its lease only at a step boundary, which fires after an operation completes, so a provider or tool call that outlives the LEASE_SECONDS lease is reaped and the run re-executed (ADR-104). The ownership guard on finishRun prevents a double settle, but not a double execution: a tool's side effect can land twice.

That is harmless for the tools shipped today — all are read-only. It is not harmless for the WRITING tools the roadmap will add. Two gaps must close before the first write tool ships:

  1. AgentRunPersister::recordStep() is fail-soft — a store hiccup is logged and swallowed. A tool can therefore execute with its audit event never persisted. For a write, that is an unrecorded mutation the run then reports success over.
  2. Nothing tells the runtime whether re-running a reaped operation is safe.

Decision 

Classify a tool's side effect and act on it.

ToolEffect 

A three-value enum: READ_ONLY, IDEMPOTENT_WRITE, NON_IDEMPOTENT_WRITE. A tool declares it by implementing ToolEffectInterface::getEffect(). A tool that does NOT implement it is READ_ONLY — the opt-in default that keeps all shipped builtins unchanged, and the reason a write must declare itself rather than be inferred. The value is a property of the CODE and is not configurable, so an administrator cannot relabel a write to dodge the guarantees. Resolution BY NAME (ToolEffectResolver::effectFor()) is stricter: an unknown name — a stale or removed tool referenced in a persisted step — resolves to NON_IDEMPOTENT_WRITE, the class that is both audit-critical and never auto-retried.

Fail-closed audit for writes 

recordStep() still never throws, but now RETURNS whether it persisted. The runtime fails a run whose WRITING tool executed but whose audit event could not be stored — AuditPersistenceFailedException — rather than continuing over an unrecorded write. Read-only and non-tool steps keep the fail-soft behaviour: a transient database blip does not fail an observe-only run.

No auto-retry for a failed write 

AuditPersistenceFailedException carries no FailureClass of its own, so FailureClassifier maps it to UNKNOWN — deliberately not retryable (ADR-095). A queued run is dead-lettered (NOT_RETRYABLE); an interactive run settles FAILED. Re-running would re-execute the write, which already ran once.

Consequences 

  • The classification is the precondition, not the whole story. Two follow-ups build on it: withholding auto-retry from a NON_IDEMPOTENT_WRITE whose own execution (not just its audit) was interrupted, and a lease-extension-before-op (operation_deadline) so a long write is not reaped mid-call. Both need this enum first.
  • ToolEffect is a separate opt-in interface, like ToolDataClassInterface (ADR-094); promoting it onto ToolInterface is a later, announced breaking change.
  • No builtin writes yet, so in production recordStep remains effectively fail-soft today — the machinery is in place for the first write tool, which is exactly when it must already exist.

ADR-112: Lease-before-op fence — no retry for an interrupted write 

Status

Accepted

Date

2026-07-23

Amended

2026-08-10 by ADR-141

Authors

Netresearch DTT GmbH

Context 

ADR-111 classified a tool's side effect and made a write's audit fail-closed. It named two follow-ups; this is the one that closes the double-execution window itself.

The queue is at-least-once (ADR-102) and the lease renews at a step boundary — after an operation completes (ADR-104). A tool call that outlives LEASE_SECONDS is reaped and the run re-executed. For a NON_IDEMPOTENT_WRITE that is a double effect: the reaper cannot tell that the run was mid-write, because nothing is recorded until after the tool returns.

Decision 

Record the in-flight write BEFORE it runs, and refuse to retry a run reaped while that record stands.

The fence 

tx_nrllm_agentrun gains a pending_effect column. A new RunTrace::beforeToolExecution() hook fires immediately before the loop invokes a tool; the runtime resolves the tool's ToolEffect and, for a WRITE, stamps pending_effect and renews the lease in one ownership-guarded write (AgentRunRepository::markPendingEffect()). When the tool's step is recorded, the same guarded write clears the fence. Read-only tools are never fenced — repeating them is always safe, so they cost no extra write. The stamp is guarded exactly like the heartbeat: a worker that has lost the run stamps nothing and stops before the side effect.

Refuse the retry 

Both retry deciders consult the fence and treat a fenced value through AgentRuntime::mayRetryAfterFence()NON_IDEMPOTENT_WRITE is not retryable, everything else (including an unset or unrecognised value) is:

  • the stale-run reaper (ReapStaleAgentRunsCommand) dead-letters a run reaped mid non-idempotent-write instead of reclaiming it onto the queue, regardless of the remaining retry budget;
  • the in-process recovery (QueuedRunFailureRecovery::recover()) dead-letters such a run even when the failure class is otherwise retryable — a transient provider blip does not make a repeated write safe.

An IDEMPOTENT_WRITE converges on repeat, so it is reclaimed normally.

Consequences 

  • The guarantee is "a non-idempotent write runs at most once": if it is reaped or fails mid-flight, the run fails rather than repeating it. It is NOT exactly-once — a write that completed but whose fence-clear did not persist is still failed, never silently retried (fail-closed toward not-repeating).
  • pending_effect defaults to '' so an un-migrated or fresh row reads as "no write in flight" — the fail-safe default.
  • The lease-before-op renewal also gives each write a full lease window at its start, but the durable fence — not the extra renewal — is what makes the refusal correct.
  • Idempotency KEYS for safe dedup of an IDEMPOTENT_WRITE remain future work; this ADR only guarantees a non-idempotent write is not repeated.

ADR-113: Fail-closed tool data-class enforcement switch 

Status

Accepted

Date

2026-07-23

Authors

Netresearch DTT GmbH

Context 

The composite tool gate (ADR-094) added a trust-zone axis that denies a tool whose data class exceeds the configuration's trust zone. It is governed by the tools.dataClassEnforcement extension setting so operators can watch it before turning it on: observe computes and reports the decision but still offers the tool, any other value enforces.

The switch read fail-open: it enforced only on an exact enforce and observed on everything else — a typo (enforced, ENFORCE), an empty value, a malformed tools section, or an extension configuration that threw on read all silently OBSERVED. A security gate that disables itself on a misconfiguration is exactly backwards: the operator believes the gate is on while a mistyped setting leaves it off.

Decision 

The switch is fail-closed. The axis observes ONLY on a deliberate observe (matched case- and whitespace-insensitively). Every other case enforces:

  • a missing value or tools section;
  • a malformed section (tools is not an array, the value is not a string);
  • a typo or any unrecognised value;
  • an extension configuration that throws on read.

This cannot over-permit: the four pre-existing gates always enforce, and turning the trust-zone axis on only ever removes an over-ceiling tool — so failing closed is strictly safer, never looser. An operator who genuinely wants observe-only must say so explicitly.

Consequences 

  • The shipped default is unchanged in this step: ext_conf_template.txt still sets tools.dataClassEnforcement = observe, so a healthy install (fresh or upgraded) reads an explicit observe and behaves exactly as before. Only a broken, missing, or mistyped setting changes — from a silent observe to a safe enforce.
  • Flipping the shipped default to enforce for new installs (while preserving observe for existing ones via an upgrade wizard) and a readiness report are a follow-up: they change behaviour for healthy installs and carry a backwards-compatibility decision, kept separate from this pure fail-closed fix.

ADR-114: Encrypt queued and suspended agent-run state at rest 

Status

Accepted

Date

2026-07-23

Authors

Netresearch DTT GmbH

Context 

An agent run parks two payloads in tx_nrllm_agentrun while it waits: the serialised request of a QUEUED run (ADR-102) and the transcript plus pending tool calls of a run suspended for approval or input (ADR-084, ADR-105). Both were stored as cleartext JSON. Unlike the event stream — which passes through the privacy filter (ADR-064) — these are stored VERBATIM, because a resume must replay them exactly. They hold user prompts, tool arguments and internal TYPO3 content, readable by anyone with database access (a backup, a replica, a support dump).

Decision 

Encrypt both columns at rest with an AgentStateCodec.

Primitive 

Delegate to nr-vault's NetresearchNrVaultCryptoEncryptionServiceInterface — the same managed-key envelope AEAD the vault uses for secrets — rather than hand-rolling crypto. nr-vault is already a hard dependency (API-key storage), so this is one crypto implementation to audit, not two, and the key management is the vault's, not ours: a per-value data key (DEK) is wrapped by a rotatable master key (MasterKeyProviderInterface, with a rotate command and a MasterKeyRotatedEvent), so the master can be rotated without re-encrypting every row. Each envelope is authenticated (a tampered or truncated row fails to decrypt rather than yielding a forged plaintext) and a fresh DEK/nonce per encryption means identical state never yields identical ciphertext.

The per-column identifier is passed as additional authenticated data (AAD)nrllm:agent-state:queued-request vs …:suspended-state — so a ciphertext authenticates only against the column it was written for: moving a queued-request envelope into the suspended-state column fails authentication.

Format and versioning 

Stored as v2: + base64( json of EncryptedData::toArray() — the wrapped DEK, both nonces, the value checksum, and the version/algorithm markers ). The version prefix distinguishes the envelope from the legacy cleartext it replaces.

Seam 

The codec lives at the repository boundary: the two columns are encrypted on write and decrypted in hydrateRun on read, so the persister and the runtime keep handling plaintext JSON and nothing above the repository changes.

Consequences 

  • Backwards compatible. decode() returns any value WITHOUT the v2: marker verbatim, so a row written before this landed (plaintext JSON) still rehydrates. New writes are always encrypted; an upgrade needs no data migration.
  • Fail-closed. When the master key is unavailable nr-vault refuses to encrypt (the fail-soft persister then does not store a QUEUED/suspended row) rather than silently storing cleartext, and a payload that fails authentication throws — the fail-soft read path then treats the run as unreadable rather than resuming a forged or corrupt state.
  • The columns stay mediumtext; the base64 JSON envelope is larger than the plaintext but well within the 16 MB bound.
  • The privacy-retention policy (ADR-064) still governs how long state is kept — encryption protects it while it exists, it does not extend its life.

Key rotation 

Covered, but only because this extension registers for it.

An earlier revision of this ADR claimed rotation was handled: "key rotation is the vault's … rotating the master key re-wraps the DEKs without touching the row ciphertext", citing nr-vault's rotate command and its MasterKeyRotatedEvent. That was wrong on both counts, and it shipped in 0.24.0. nr-vault's vault:rotate-master-key re-wrapped the data keys it found by walking its own tx_nrvault_secret; the data key of an agent-state envelope lives in tx_nrllm_agentrun, where that walk never reached it. And the event, though declared and documented upstream, was never dispatched from anywhere — so this extension could not have subscribed to learn a rotation had happened either. Encrypting these columns was therefore a delayed data-loss bug: the first master key rotation made every encrypted queued and suspended run permanently unreadable, silently, because the rotation succeeded at everything it knew about.

The correction is recorded rather than edited away, because a wrong claim about where data survives is worth remembering.

nr-vault now exposes ForeignEnvelopeRotatorInterface (nr-vault ADR-033), and AgentStateEnvelopeRotator implements it. Registering it is what makes rotation cover these rows:

Configuration/Services.yaml
Netresearch\NrLlm\Service\Tool\AgentStateEnvelopeRotator:
  tags: ['nrvault.foreign_envelope_rotator']
Copied!

The rotator re-wraps both columns inside the vault's own rotation transaction, so a failure rolls the whole rotation back rather than leaving half the installation under a key the operator is about to destroy. It re-wraps the DEK layer only — the payload is never decrypted — and it covers BOTH the current nrv1: marker and the legacy v2: one written by 0.24.0-0.25.x, since both are sealed under the same master key. Default query restrictions are removed so no row is hidden from the pass, and the walk pages by uid rather than OFFSET because it is rewriting the rows as it goes.

Requires nr-vault ^0.13.0. On an older nr-vault the interface does not exist, so the constraint is a hard one rather than a suggestion.

ADR-115: Tool data-class enforcement is the default for new installs 

Status

Accepted

Date

2026-07-23

Authors

Netresearch DTT GmbH

Context 

The trust-zone tool gate (ADR-094) shipped in observe mode: it computed and logged the decision but still offered an over-ceiling tool, so an operator could watch it before turning it on. ADR-113 made the switch fail-closed — only an explicit observe observes — but the shipped DEFAULT was still observe, so a brand-new install offered over-ceiling tools until someone opted in to enforcement. A security control that is off by default on a fresh install is the wrong default.

The reason it shipped observe-by-default was to avoid an upgrade silently stripping tools from a working setup. That constraint is real, but it applies to EXISTING installs, not new ones.

Decision 

Ship enforce as the default and preserve observe for existing installs with an upgrade wizard, so the two cases are treated differently:

  • New installext_conf_template.txt now sets tools.dataClassEnforcement = enforce. Combined with the fail-closed read (ADR-113), a fresh install enforces without any operator action, and a missing or mistyped value also enforces.
  • Existing installDataClassEnforcementDefaultUpdateWizard pins an explicit observe so the flip changes nothing for a setup that relied on the old default, and its description points the operator at the run log and the switch to enforce when ready.

Distinguishing the two 

The wizard fires only when a provider is configured (you cannot run a tool without one, so a fresh install has none) AND the enforcement mode was never explicitly stored — read from the raw $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS']['nr_llm'] rather than the template-merged ExtensionConfiguration::get(), which cannot tell "relying on the default" apart from a deliberate choice. An operator who already chose a mode is left untouched: an explicit enforce is respected, an explicit observe already matches.

Consequences 

  • A new install is safe by default; the tool gate no longer waits for an opt-in.
  • An existing install's behaviour is unchanged across the upgrade — the wizard makes its implicit observe explicit before the default flips under it.
  • The change is announced in the extension configuration label and the wizard description; combined with the fail-closed read, an operator cannot end up in observe by accident, only by deliberate choice.
  • A dedicated readiness report (how many tools each configuration would lose under enforce) remains future work; the run log already records what enforcement would do while an install is in observe.

ADR-116: Central tooling authority — nr_llm owns builtin + MCP tools 

Status

Accepted

Date

2026-07-22

Authors

Netresearch DTT GmbH

Context 

Two tooling systems exist in the stack today, and only one of them is nr_llm.

nr_llm is already the tooling authority for its own agent runs. It ships  45 builtin tools under Classes/Service/Tool/Builtin/ (among them FetchLogsTool, GetLastExceptionTool, ListDeprecationsTool, GetSystemStatusTool, SiteRagQueryTool and GetPageContentTool), collects them in ToolRegistry (Classes/Service/Tool/ToolRegistry.php), and executes them through ToolLoopService (ADR-038) behind the tool gate (ADR-093) and the global availability state (ADR-039). AgentRuntime (ADR-101, Classes/Service/Agent/) is the public application service that owns the run lifecycle — approval (ADR-084), guardrails (ADR-085) and context-window bounding (ADR-107) — with the interface run / enqueue / runQueued / approve / submitInput / cancel / events / status.

The separate nr_mcp_agent extension is a second, parallel tool stack that bypasses all of the above. It is itself an MCP client (Classes/Mcp/McpToolProvider, Classes/Mcp/McpConnection — a stdio JSON-RPC client over proc_open — and McpServerRepository over the tx_nrmcpagent_mcp_server table, whose transport field offers stdio and sse). It re-implements its own agent loop in ChatService::runAgentLoop and sources tools only from external MCP servers (e.g. the suggested hn/typo3-mcp-server dependency), mapping the MCP wire shape onto nr_llm's ToolSpec. It uses nr_llm merely as a completion provider (ProviderInterface), never its ToolRegistry or AgentRuntime.

The result is two agent loops with two independently maintained sets of fail-closed, approval, guardrail and context rules, and two disjoint tool sources (builtin-only in nr_llm, MCP-only in nr_mcp_agent) that no consumer can obtain together.

Decision 

nr_llm is the single tooling authority for the AI stack. All tooling — builtin and MCP — is aggregated and executed there; consumers never reach an MCP server directly.

  • Add an MCP client to nr_llm. nr_llm gains the ability to connect to external MCP servers over HTTP, list their tools, and register those tools into ToolRegistry alongside the builtin tools. MCP tools then flow through the exact same path as builtins: ToolRegistryToolLoopService / AgentRuntime, subject to the same tool gate (ADR-093), the same availability state (ADR-039), the same approval (ADR-084), guardrail (ADR-085) and context-window (ADR-107) enforcement.
  • Consumers obtain all tooling exclusively via nr_llm. A consumer (for example a backend AI-chat module) takes its tools from ToolRegistry and drives runs through AgentRuntime. It never opens an MCP connection itself. MCP servers are wired only through nr_llm.
  • One loop, one trust boundary. There is a single agent loop (AgentRuntime over ToolLoopService). Builtin and MCP tools share the one tool-data trust zone (ADR-094) and the one gate, so a tool's origin does not change how it is authorised, approved or audited.

Consequences 

  • The MCP-client capability moves out of nr_mcp_agent and into nr_llm: the connection, tool-listing and schema-normalisation logic that today lives in McpToolProvider / McpConnection becomes an nr_llm concern, and the server-configuration storage (transport, command/arguments, url/auth token) moves with it.
  • ToolRegistry becomes the aggregation point for builtin and MCP tools; the allow-list, availability toggle and gate apply uniformly regardless of where a tool came from.
  • AgentRuntime is the single agent loop. nr_mcp_agent deletes ChatService::runAgentLoop and stops assembling its own lifecycle; its divergent fail-closed / approval behaviour disappears with it.
  • nr_mcp_agent is reduced to a thin backend chat UI — module, toolbar and conversation store — driving AgentRuntime. With MCP gone it is arguably mis-named: a rename (candidate nr_llm_chat) or an outright fold-in to nr_llm are both on the table (see follow-up).
  • hn/typo3-mcp-server becomes an MCP server that nr_llm connects to, not a per-consumer composer dependency; any number of external MCP servers attach the same way.
  • New public surface lands in nr_llm (MCP client configuration + registration). It is a minor-release growth path and will carry its own ADR when the implementation is designed; the public-service count authority (ADR-101) is updated then, not here.

Transports: HTTP only 

This ADR first named "stdio / http / sse". That list was wrong in two ways and is corrected here, because it would otherwise be read as a build order.

sse is not a peer of http. It is a response framing for an HTTP connection, not a separate transport to select. Offering both as values of one field invites a configuration that cannot be satisfied — which is what the transport field in nr_mcp_agent's own table already shows, where stdio and sse are the only values and plain http does not exist.

stdio is out of scope, and not merely for performance. It means spawning a process on the TYPO3 host from a request, with the command line taken from an operator-editable record. Every control in the tool stack classifies a tool's OUTPUT (ADR-094) or authorises its CALLER — nothing classifies what a tool may do to the host it runs on. "One registry, one gate" is a true statement about offering, approving and auditing a tool; it says nothing about launching a process, and it must not be read as covering one. If a stdio transport is ever wanted, it belongs on the CLI and queue paths behind an explicit allow-list of executables, decided in its own ADR.

The client therefore speaks HTTP, and only HTTP.

What an MCP tool resolves to, and why it needs a declaration 

An MCP tool arrives without a group, and the two resolvers answer that differently — one fail-closed, one fail-open. Both answers are wrong for MCP, in opposite directions, and an implementation that does not address both is not "the same gate as a builtin".

Data class fails closed to unusable. A tool whose group is unknown resolves to SECRET_ADJACENT, which only a LOCAL trust zone permits. Against every hosted provider such a tool is withheld. So MCP tools are not merely "classified strictly" by default — they do not run at all outside a local model.

Effect fails open. A registered tool that declares nothing resolves to READ_ONLY, which is correct for the builtins (all of them read) and wrong for an MCP server, where writes are ordinary. An undeclared remote write would lose the write fence and the fail-closed audit of ADR-111.

The implementation therefore requires a per-server data-class declaration by the operator, and must treat an externally-sourced tool as a write unless declared otherwise — the inverse of the builtin default. Neither is a weakening: the first replaces "denied everywhere" with a stated ceiling, the second replaces a guess with the strict answer.

Why any of this is nr_llm's problem at all, given that the MCP server authorises its own resources: three things the server cannot see. Its output travels onward to an LLM provider we chose, under a data-protection obligation that is ours. Its authorisation is against one operator credential, so without a gate here every backend user inherits the full rights of that credential. And the write fence is our own retry bookkeeping, about our queue, which the server knows nothing about. Securing the resource stays the server's job; where its answers flow and in whose name we ask are ours.

Migration and follow-up 

Implementation is separate follow-up work; this ADR records the target only.

  • Build the MCP client in nr_llm: the HTTP transport, the tools/list handshake, inputSchema-to-provider-schema normalisation (the concern McpToolProvider already solves), and registration of the resulting tools into ToolRegistry.
  • Move the MCP server configuration model (transport, command / arguments, url / auth_token) into nr_llm.
  • Repoint nr_mcp_agent's ChatService onto AgentRuntime and delete its Classes/Mcp/ client and runAgentLoop.
  • Decide rename versus fold-in for nr_mcp_agent as a discrete step.
  • Reconcile streaming and context-window parity for MCP-sourced tools per the scope note in ADR-107.

ADR-117: Withdraw the backend capability permissions 

Status

Accepted

Date

2026-07-27

Supersedes

ADR-023

Authors

Netresearch DTT GmbH

Context 

ADR-023 registered every ModelCapability case as a native TYPO3 backend group permission and shipped CapabilityPermissionService to resolve the check. It deliberately stopped there: the ADR states that it ships "the registration + check primitive" and does not gate existing calls, and lists injecting the check into the feature services as a rejected alternative for that release.

The follow-up never came. Every backend group record therefore shows eleven checkboxes — chat, completion, embeddings, vision, streaming, tools, JSON mode, audio, image, text-to-speech, transcription — that look like an access control and change nothing. An administrator who unticks "vision" for an editor group gets no warning and no effect.

Decision 

Remove the registration, the service and its interface rather than wiring the check in.

Three findings decided it, each verified against the code rather than inferred:

There is no chokepoint to gate. The middleware pipeline covers chat, completion, embedding, vision, tools and every specialized call, but streaming is routed to StreamingDispatcher instead and bypasses the pipeline entirely. ModelCapability::STREAMING — the capability an operator is most likely to withhold for cost reasons — would be structurally unenforceable, so the control would be incomplete by construction.

The check's polarity turns enforcement into a bypass. CapabilityPermissionService::isAllowed() returns true when no backend user is present. The queue worker never populates $GLOBALS['BE_USER']ActingBackendUserResolver builds a detached user object instead — so the same run would be denied synchronously and allowed after enqueue(). Reversing the polarity instead denies every user-less path: service-account runs, runs whose initiator was deleted or disabled, and the evaluation commands. ServiceAccountScope has no capability-shaped case, so a CLI job cannot be granted "may use embeddings" at all.

It would be inert where the UI implies it applies. All thirteen nr_llm backend modules are 'access' => 'admin' and administrators bypass the check by definition, so unticking a capability would change nothing inside nr_llm's own interface. Only third-party consumers would be affected — a half-enforcement that is harder to reason about than no enforcement.

Alternatives considered 

Wire the check in behind an opt-in switch, with an upgrade wizard that pre-ticks all eleven boxes for existing groups. The migration shape exists in this codebase (ADR-115 and DataClassEnforcementDefaultUpdateWizard) and would work. Rejected on value, not on feasibility: it would buy an incomplete control over third-party consumers only, at the cost of a new chokepoint, a wizard, and an answer for every user-less execution path.

Keep the checkboxes and relabel them as reserved. Rejected: a control that has to be labelled "has no effect" is worse than its absence.

Consequences 

  • Breaking for consumers. CapabilityPermissionService and CapabilityPermissionServiceInterface are removed, along with the DI alias and the Documentation/Developer/CapabilityPermissions.rst guide that described their use. A consumer injecting either must drop the dependency. Nothing inside nr_llm called them.
  • The checkboxes disappear from the backend group form. Values previously ticked remain in be_groups.custom_options as inert strings (nrllm:capability_*). They are never read again; no migration removes them, because an unknown entry in that field has no effect.
  • Access control is unchanged, because there was none to change. The gate that does work stays what it was: the per-configuration allowed_groups relation, enforced at runtime by LlmConfigurationService::hasAccess() and ConfigurationResolver (ADR-070).
  • The door stays open. Should per-capability permissions become worth it, they should be designed onto AiActorContext — which already carries the acting identity across the synchronous, queued and service-account paths — rather than onto the ambient superglobal this ADR removes.

ADR-118: Verify the specialized services from the backend 

Status

Accepted

Date

2026-07-28

Authors

Netresearch DTT GmbH

Context 

Translation, image generation and speech are configured through the Extension Configuration: one nr-vault identifier per credential (translators.deepl.apiKeyIdentifier, image.fal.apiKeyIdentifier, providers.openai.apiKeyIdentifier for the DALL·E / Whisper / TTS family).

Nothing in the backend could reach any of them. Every entry point terminates in a plain chat completion:

  • the Playground drives AgentRuntimeInterface with a single text prompt;
  • the "Test" buttons on provider, model and configuration records call $adapter->complete();
  • TaskExecutionService::execute() never branches on TaskCategory, TaskInputType or TaskOutputFormat — it always calls completeWithConfiguration(), so a translation or image Task cannot be defined.

A repository-wide search confirms it: outside their own directories, TranslationService appears only in doc comments, and the image services not at all. They are implemented, DI-wired and unit-tested, and reachable only by a consuming extension that injects them.

The practical consequence is that an operator pastes a DeepL identifier into the Extension Configuration and has no way to learn whether it works. The failure surfaces later, in a consumer, as a runtime error.

Decision 

Add backend endpoints that exercise translation and image generation, as verification surfaces rather than features.

Three AJAX routes on SpecializedTestController, rendered as two cards on the existing test page:

Route Purpose
nrllm_test_translate Translate a snippet. With no translator named, the LLM path runs (no specialized credential needed); naming one routes to it, which is the case worth testing after configuring its vault identifier.
nrllm_test_translators List the registered translators and whether each is configured, so the picker shows the answer before anything runs.
nrllm_test_image Generate one image with either the OpenAI or the FAL service.

Nothing is persisted. A translation is returned as text. An image is returned as whatever the provider produced — a URL from FAL, a data URI from the OpenAI family — rendered in the browser and gone on reload. Storing it, moving it into FAL or attaching it to a record is the consuming extension's job and deliberately out of scope: nr_llm has no storage wiring for generated images and this ADR does not add one.

A missing credential is reported as 503, not 500. That distinction is the whole point of the endpoints, so ServiceUnavailableException is caught separately and answered with a message naming the Extension Configuration. A runtime failure of a configured service stays a 500 with the detail in the log, per the existing error-sanitising convention.

ImageGeneratorInterface 

DallEImageService::generate() takes an ImageGenerationOptions object as its second parameter, FalImageService::generate() a model identifier. The divergence is deliberate — the providers model a request differently — and stays.

The new ImageGeneratorInterface declares only the part they share, generate(string $prompt): ImageGenerationResult. Both classes already default every parameter after the prompt, so neither changes behaviour by implementing it. It lets a caller that wants no provider-specific control treat the two interchangeably, and it makes them mockable — both are final, which would otherwise leave the controller untestable.

Because both services satisfy the interface, the controller's two image arguments are bound explicitly in Services.yaml; the type alone cannot disambiguate them.

Consequences 

  • An operator can answer "does this credential work?" without writing consumer code, which is what the Extension Configuration fields have been missing since they were introduced.
  • The endpoints spend real provider quota on each run. They are admin-only (ADR-037 guards every action), single-shot, and input is capped at 5000 characters.
  • Speech (Whisper, text-to-speech) stays unreachable. Transcription needs an audio upload and synthesis produces a binary response; both raise the same storage question this ADR declines to answer for images, with less to gain.
  • ImageGeneratorInterface is public surface. A future image provider is expected to implement it.

ADR-119: Where the backend modules live — Administration, for now 

Status

Accepted (deferred — the placement is not finally settled, see Revisit)

Date

2026-07-28

Authors

Netresearch DTT GmbH

Context 

nr_llm registers a parent module nrllm with twelve submodules — providers, models, configurations, tasks, snippets, skills, tools, playground, agent runs, analytics, setup wizard and overview — under TYPO3's Administration section. The question raised: should this become its own top-level section, a sibling of Content, Media, Sites, Administration and System, and should it be called "LLM" or "AI"?

Two arguments were made against a section and both turned out to be worthless, which is why they are recorded here rather than quietly dropped:

"They are all admin-only modules, and Administration is the section for admin-only modules." Access level is not the grouping principle. In the core, site holds site_configuration (access => admin) next to link-management (access => user), and system is systemMaintainer — stricter than admin, not the same. Sections mix access levels, so this explains nothing.

"A move is expensive." It is — the nrllm route and its bookmarks, two setShortcutContext calls, the docheader submodule dropdown, 33 references across 20 documentation files, roughly 17 backend screenshots, and t3_cowriter's position => ['after' => 'nrllm'] anchor. But cost answers "what does it take", not "what is right". The two must not be confused.

What the sections actually are 

The core's own definitions (cms-core/Configuration/Backend/Modules.php) group by subject: content is where writing happens, media where files are worked with, site where sites are configured, system the installation itself. Administration is where the instance is administered.

The core also, in v14, added integrationsparent => 'admin', dependsOnSubmodules, showSubmoduleOverview — which is structurally the same shape as nrllm. That is the core's own answer for a cohesive group of admin-facing extension modules: a container under Administration, not a new top-level section.

The argument that actually decides it 

Everything above points at Administration — as long as nr_llm's modules stay a place where a capability is configured and inspected rather than a place where work is done.

They do not stay that, and the reason is not the editor action API. That API (see the roadmap) surfaces AI actions inside the consuming extension's UI, on the record the editor is already working on, so it argues for leaving nr_llm where it is: an editor would never open an nr_llm module.

But a consumer's UI can only ever show data in that consumer's context. An editor also needs cross-consumer answers about themselves:

  • what is my budget, and how much of it have I used?
  • which tools am I allowed to use?
  • which skills apply to me?
  • what did I run, across every consuming extension?

and a lead editor needs the same for their editors, or for a group. None of that is expressible in a consumer's UI, because it is by definition not about one consumer. It is editorial self-service, and it has no home today: the analytics module aggregates instance-wide and is admin-only, no per-user history exists, and per-user budgets have no user-facing view at all.

Once those surfaces exist, nr_llm's module tree is no longer an administration toolset, and the subject argument flips: AI becomes a place where work happens, on a par with Content and Media.

Decision 

Keep the modules under Administration for now. Do not treat this as settled.

The cross-consumer editor surfaces do not exist yet, and building a top-level section for users who cannot yet be served by it would be premature. Nothing about the current placement blocks them.

Revisit 

Reopen this the moment the first cross-consumer editor surface is planned — a personal usage-and-budget view, a personal run history, or a lead-editor view over a group. That is the trigger; not a count of modules, not a preference about menus.

When it is reopened, these are settled in advance:

  • The section is called "AI", not "LLM". It would hold tasks, skills, tools, agent runs and analytics — not just language models. Integrators look for AI.
  • The identifier is vendor-scoped (netresearch_ai), never a bare ai. Module identifiers merge last-package-wins, so a generic top-level identifier is a shared namespace with no owner: the label and icon would depend on package load order, and removing the owning extension would strip the routes of any foreign submodules parented to it.
  • Twelve flat entries do not move as they are. They read as a dumping ground at any level. Group them by subject first — setup (provider, model, configuration), authoring (tasks, skills, snippets), operation (tools, playground, runs, analytics) — and let the section hold three or four entries.
  • Old routes keep working. Keep the submodule identifiers and explicit paths, repoint setShortcutContext from nrllm to nrllm_overview, and give that module 'aliases' => ['nrllm'] so existing bookmarks resolve — valid only once nrllm is no longer a registered identifier, since an alias is shadowed by a real module of the same name.

Consequences 

  • No code changes. The placement, identifiers, routes and documentation stay as they are.
  • The discoverability problem is real and remains: TYPO3's module menu renders two levels, and nr_llm's twelve submodules sit at the third, so they are invisible from the main menu. That is worth fixing on its own terms — by strengthening the Overview as the hub, or by grouping the twelve — and does not require the top level.
  • If the editor surfaces are built without reopening this ADR, they will land in an admin-only section where their users cannot reach them. The revisit trigger exists to prevent exactly that.

ADR-120: The agent loop's tool gate is a required collaborator 

Status

Accepted

Date

2026-07-29

Authors

Netresearch DTT GmbH

Context 

ToolLoopService took three security collaborators as optional constructor arguments: the composite tool-call policy (ADR-094: Tool data classes and provider trust zones), the per-configuration allow-list resolver, and the input schema validator (ADR-105: Typed user-input suspension (WAITING_FOR_INPUT)). Each defaulted to null, and each null changed what the loop enforced without changing anything a test or a linter could see.

The roadmap named the fix as "make them required, and give tests an explicit lean wiring (Null* implementations plus a Testing builder) instead of implicit absence". Working it through, two of the three premises turned out to be wrong, both in the unsafe direction. They are recorded here because the change deliberately does something other than what the roadmap said.

The allow-list resolver was already dead code in production. resolveOfferedNames() returns the policy's verdict as soon as a policy is wired. Everything after that return — the enabled-set intersection, the admin filter, the allow-list intersection — is unreachable, and the resolver was read at exactly one line inside it. Production always wires a policy, so the argument enforced nothing there. Making it required would have added a mandatory argument that no production code path consults, while ToolCallPolicy performs the identical check itself.

A ``Null`` policy would have been weaker than the status quo. Today a null policy falls through to that legacy chain, which — narrower than the composite gate, but real — still intersects with the enabled set, still drops admin-only tools for a non-admin, still applies the configuration's grant. An allow-all NullToolCallPolicy returns the caller's requested list unfiltered and disables all three at once. It would have been the exact failure mode the item exists to remove, shipped as its remedy. A deny-all Null is unusable by the tests that motivated it.

Decision 

The composite policy becomes a required constructor argument. The allow-list resolver is deleted along with the now-unreachable legacy chain. The schema validator becomes non-nullable with a new JsonSchemaValidator() default — it is stateless and has no constructor, so there is no wiring under which the defence-in-depth re-validation can be absent.

No ``Null`` implementation of any gate is written, in Classes/, Classes/Testing/ or Tests/. A gate that does not exist cannot be wired by accident. Tests that need a loop construct the real ToolCallPolicy — it needs only a registry, an availability service and three stateless resolvers, so this costs a helper rather than a fixture.

The availability service is no longer a constructor argument either. Deleting the legacy chain left nothing in the loop reading it; the policy owns that check now.

Consequences 

The loop can no longer be constructed in a state where it enforces less than production does. Every test that exercises it exercises the real gate, including the trust-zone axis that the legacy chain never had.

That axis was immediately load-bearing. Tests that passed a bare LlmConfiguration were relying on a configuration with no provider, which fails closed to the EXTERNAL_GLOBAL zone and a EDITOR_CONTENT ceiling — so the real gate withholds every tool above that class. Those configurations now declare a LOCAL-zone provider. This is a finding about the fixtures, not a concession: a run with a provider-less configuration really does get fetch_logs withheld in production today, and no functional test noticed because no functional test wired the gate.

A new container test pins the production wiring: that the loop resolves at all proves the required argument is autowirable, and that the bound policy is the composite ToolCallPolicy proves the container does not reach a narrower implementation that would satisfy the type while deciding less.

What this does not close. ToolCallPolicyInterface is a public alias, so an install may bind its own implementation. That is an extension point, not a hole, and a test in this package cannot observe a downstream container. Required arguments turn "absent wiring silently weakens the gate" into "substituted wiring deliberately replaces it" — a smaller and much more visible surface, not an eliminated one.

Alternatives considered 

Follow the roadmap literallyNull* implementations plus a Testing builder. Rejected: see the context above. The Testing builder would also have had to construct the Null gates, which merely moves the choice of a weakened gate from the constructor into the builder.

Keep the allow-list resolver required rather than deleting it. Rejected: a required argument that no code path reads is worse than an optional one, because it reads as enforcement.

Give tests a permissive double instead of the real policy. Rejected. The one functional test that wires real collaborators would then assert a double's behaviour — reproducing, inside the test suite, the substitution this change removes from the constructor.

ADR-121: Conversations are bounded where they are assembled 

Status

Accepted

Date

2026-07-29

Authors

Netresearch DTT GmbH

Context 

The agent loop bounds its transcript against the model's context window (ADR-107: Agent-loop context-window management). Conversations do not. ConversationService::send() replays the whole persisted history on every turn — system prompt, every stored message, then the new one — so a long conversation grows until the provider refuses it. The user gets an error instead of an answer, and gets it abruptly: the turn before worked.

Decision 

Bound the transcript in ConversationService::send(), using the existing ContextWindowManager. The configuration for the turn is resolved before the transcript is assembled, because the bound depends on the model the request will actually go to.

Not in the shared completion path. The obvious-looking alternative was to put the fit into the terminal that every configuration-driven call passes through, so conversations and everything else would be covered at once. That would have broken working callers. Document analysis, translation and plain completion all funnel through it, and they send a single large message. The manager drops whole older turns; with one turn there is nothing to drop, so it reports that the request does not fit and the caller throws. Those calls work today. A conversation is the only path that grows across turns, and it is the only path bounded here.

No re-fit on fallback. The roadmap offered planning against the smallest model in the fallback chain, or re-fitting when a fallback fires. The second cannot work as the code stands: a context overflow is not classified as retryable, so the fallback chain is never walked for it. The first was rejected as too costly for the benefit — it would shrink every conversation to the smallest model that might serve it, including the overwhelming majority of turns that never leave the primary.

When even the floor does not fit, send it anyway. The manager keeps the system prompt, the opening exchange and the newest turn; if that still exceeds the budget it says so. The request is sent regardless, and a warning is logged. The estimate deliberately errs high, so this often succeeds. When it does not, the provider's own error is exactly what the caller would have received before. Refusing here would end a conversation the provider might still have answered.

A trim is recorded, not just logged 

Trimming means the model answers without part of the history. A reader who cannot see that will misread the answer.

The number of dropped turns is persisted on the user row of the turn, in tx_nrllm_ai_session_message.dropped_turns. The user row is written before the provider call, so the fact survives a failed call; the assistant row only exists on success. NULL means no fit was evaluated — a row from before this change, or a session without a bound configuration. 0 means the fit ran and kept everything.

The stored history is never shortened. It is the audit record, and it stays complete; the column records what the model saw, which is a different fact.

A log line alone was not enough. The agent loop can rely on one because a run also carries a termination reason and an inspector view; a conversation has neither.

The count is not written into a governance event. That table's decisions are denials and gates, and the dashboards render them as blocks. A routine, working degradation does not belong in a chart operators read as "things that were refused".

Consequences 

A long conversation now shortens instead of failing. The oldest exchanges are the ones the model stops seeing, which is the least surprising thing to lose.

Nothing reads dropped_turns yet — there is no conversation UI in the backend. It is written because the fact cannot be reconstructed afterwards, while a reader can be added whenever one is needed.

Two conversation shapes are still unbounded, and both are follow-ups rather than oversights: a session opened without a bound configuration (there is no model to measure against, so the manager has no window to fit), and a turn whose options pin a provider directly rather than naming a configuration.

On the token estimate 

The estimator counts UTF-8 bytes, not characters, and this was reviewed and kept. Bytes per token stay roughly comparable across scripts while characters per token do not: Latin text runs about one byte per character, CJK about three, and tokens track the byte count far more closely than the character count. Dividing bytes by 3.5 lands within a modest margin for both. Counting characters instead would under-estimate CJK by roughly a factor of three — the direction that fails at the provider — so the apparently obvious fix would have been a significant regression.

ADR-122: The side-effecting tool contract waits for a side-effecting tool 

Status

Accepted (premise expired — see ADR-135 and ADR-136)

Date

2026-07-29

Amended

2026-08-09 by ADR-135 and ADR-136

Authors

Netresearch DTT GmbH

Context 

The roadmap asked to promote the tool effect declaration (ADR-111: Tool side effects and fail-closed audit for writes) into a tool-facing interface with an idempotency scope and an optional preview, "so writing tools can be built against a contract instead of a convention".

Three facts decided this differently.

No tool writes. All 44 builtin tools read. None implements ToolEffectInterface; every one takes the READ_ONLY default. The write path exists and is correct — the lease-before-op fence, the fail-closed audit, the retry refusal — but nothing exercises it with a real tool.

The idempotency scope has no reader. The only place an effect crosses a process boundary is the pending_effect column, and its single consumer reduces it to one bit: may this run be retried. A scope value would be a field nothing branches on.

The preview has no caller and no display. The one surface that could show it is the approval card, and that is reachable only for tools implementing a separate marker interface, which declaring an effect does not imply. It would also have to run inside the reviewing administrator's request rather than the run's actor context, which ADR-083: Conversation sessions and memory forbids reading around.

Promoting getEffect() onto ToolInterface would additionally break every builtin and every third-party tool built against the public DI tag, for no behavioural gain.

Decision 

Do not build the interface, the scope or the preview yet. A contract designed before the first writing tool guesses at the shape that tool needs, and this codebase has just spent three changes removing exactly that kind of guess: an argument that looked like enforcement and was read by nothing.

Do the three things that are real today.

Clear the write fence on requeue. applyRequeueSet cleared the claim and the lease but left pending_effect standing. A requeued run has not started its next attempt, so the fence describes a write that is no longer in flight — and a standing NON_IDEMPOTENT_WRITE dead-letters the run whatever the retry budget says. Narrow but reachable: a step naming a tool the registry no longer knows resolves fail-closed to NON_IDEMPOTENT_WRITE, so a tool removed or renamed between attempts stamps the fence for real.

Pin which tools write. A new coverage test asserts the set of tools resolving to a write effect, currently empty. The declaration is opt-in, so forgetting it is silent and costs the tool its fence and its audit. The test turns that silence into a failing assertion the first time someone adds a writer.

Correct the roadmap. It claimed "at-least-once queue delivery with idempotent tool effects" as shipped. What shipped is a declared effect classification and a fail-closed write audit, with no writing tool to exercise either.

Consequences 

Nothing changes for the 44 existing tools.

The first writing tool will find the machinery waiting for it and the coverage test asking it to declare itself. Whether it then needs an idempotency scope, a preview, or something neither of those describes is a question that tool can answer and this one cannot.

Revisit when 

A tool that mutates is proposed. At that point the three deferred pieces should be reconsidered against what it actually needs — starting from the tool, not from this ADR.

One design constraint surfaced while investigating and is worth recording so it is not rediscovered: making a completed non-idempotent write safely replayable would require a tool-result dedup store, and that in turn changes the stale-run reaper's unconditional dead-letter policy. Those two move together or not at all.

ADR-123: One catalogue of secret shapes for every masking path 

Status

Accepted

Date

2026-07-29

Authors

Netresearch DTT GmbH

Context 

Three places in this extension masked secrets, and each knew a different subset of what a secret looks like:

  • RedactsSecretsTrait, behind the response and prompt guardrails (ADR-085 / ADR-087), knew modern OpenAI project keys, classic and fine-grained GitHub PATs, AWS and Google keys, Slack tokens and bare JWTs.
  • ContentRedactor, which decides what gets written to the database at privacy level REDACTED (ADR-064), knew only credential-bearing URLs, Bearer headers, a narrower sk- pattern and e-mail addresses.
  • GetEnvTool matched on variable names only.

Measured against twelve secret shapes, the guardrail masked eleven and the privacy redactor seven fewer. The consequence was not theoretical: a secret correctly stripped from a prompt on its way to a provider was still persisted in cleartext, because the two paths disagreed about what a secret is.

GetEnvTool's name-only rule had a second version of the same problem. The tool is admin-only but enabled by default, and its output egresses to the configured LLM provider. A variable whose name gives nothing away leaked its value verbatim:

GITHUB_PAT=ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
STRIPE_LIVE=sk_live_<24 alphanumerics>
Copied!

Neither name contains PASS, KEY, SECRET or TOKEN, so neither was redacted.

Decision 

Move the shapes into one place, Netresearch\NrLlm\Utility\SecretShapeRedactorTrait, next to the ErrorMessageSanitizerTrait it builds on, and have all three consumers read from it.

Two entry points, opposite failure modes 

preg_replace() returns null when the regex engine gives up, and a bare (string) cast turns that into ''. On a redaction path, wiping the entire content looks exactly like a successful, very thorough redaction. The two kinds of caller need opposite handling, so the trait offers both explicitly:

  • redactSecretShapes()fails open, keeping the text. For the guardrails: losing a model's whole response, or an outgoing prompt, because one pattern hit a backtrack limit is worse than missing that pattern.
  • redactSecretShapesStrict()fails closed, returning null. For GetEnvTool: a value the redactor could not fully inspect is withheld rather than forwarded to a third party.

GetEnvTool checks both name and value 

The name rule is kept and the value rule is added, because each catches what the other misses: a name rule catches an empty or unrecognised-format secret (DB_PASSWORD=hunter2) that no shape pattern would match, and a value rule catches a recognised secret under a neutral name. A test asserts that the three neutral fixture names are not matched by the name pattern, so the value path cannot silently stop being exercised if someone widens the name rule later.

The tool keeps its own, stricter URL-userinfo pattern, which masks the whole user:password@ rather than just the password. In a provider error message a username is useful context; in a listing that egresses to a third party it is half a credential. Tightening the shared trait instead would have silently changed what every provider error message discloses.

E-mail masking stays with the privacy redactor 

An address is personal data, not a secret, and the guardrails must not begin stripping addresses out of prompts and responses — removing one changes what the text says. ContentRedactor masks them because it writes to storage; the guardrails do not.

Consequences 

  • The privacy redactor now masks every shape the guardrails do, so the persist path can no longer be weaker than the egress path.
  • GetEnvTool no longer leaks secret-shaped values under harmless names. A connection-string variable still shows its host and path — the context the tool exists to provide.
  • New shapes (Stripe secret and publishable keys, SendGrid) were added while consolidating, so all three consumers gained them at once.
  • Adding the next shape is a one-line change in one file instead of three edits by someone who has to know all three places exist.
  • This remains best-effort. It recognises these shapes and nothing else, and does not weaken the rule that secrets belong in nr-vault, never in a prompt, a column or an environment variable.

The catalogue lives in nr-vault 

nr-vault carried the same knowledge for its plaintext scanner, and it had drifted the other way — it knew Stripe, SendGrid, Twilio, Mailchimp and PayPal but not OpenAI project keys or fine-grained PATs. The merged superset now lives upstream in NetresearchNrVaultSecretSecretPatternLibrary (nr-vault ADR-031), which is the right long-term home: one catalogue for every Netresearch extension rather than one per extension.

This extension reads it. SecretShapeRedactorTrait iterates SecretPatternLibrary::all() and ErrorMessageSanitizerTrait iterates SecretPatternLibrary::urlCredentials(), so no secret regex is defined in nr-llm any more. Both gained shapes in the process — Slack legacy tokens, Mailchimp and SendGrid keys, Stripe publishable keys — without any change here.

Read through the library's STATIC methods, not the injectable SecretRedactorInterface. These traits are used from objects the container never builds: a provider exception, and ControllerBackendResponseErrorResponse, which callers construct with new. A trait that reached into the DI container to mask a string would be a worse dependency than a static call to a pure pattern list.

Requires nr-vault ^0.13.0.

Why the sanitiser stays a narrower subset 

ErrorMessageSanitizerTrait deliberately applies only the two URL shapes, not the whole catalogue. It runs on error messages, where the job is to strip the credential a client library put into a request URL — not to scan arbitrary prose for every vendor token shape. Callers that want the full catalogue use SecretShapeRedactorTrait, and the guardrails do.

ADR-124: A provider key can be set from the command line 

Status

Accepted

Date

2026-08-04

Authors

Netresearch DTT GmbH

Context 

nr-llm owns provider credentials. The setup wizard takes a plaintext key, generates the identifier, stores the secret and writes that identifier onto the provider record (ADR-012). A consuming extension therefore never needs to know where the secret ends up — it configures a provider and refers to it.

That encapsulation held only for someone sitting in the backend. No console command stored a key, so every unattended install — a container entrypoint, a DDEV install script, CI provisioning, a throwaway review instance — had to call nr-vault's vault:store itself and then hand-write the identifier into the provider record.

Two things followed. Consuming extensions grew a dependency on nr-vault's CLI and on the knowledge that nr-llm keeps its keys there; nr_repurpose documented exactly that as part of its own setup. And the two paths produced different records: the wizard writes provenance metadata alongside the secret, a hand-rolled vault:store writes none, so what an audit sees depends on how the instance happened to be provisioned.

Decision 

Add nrllm:provider:set-key <provider> , which does from a script exactly what the wizard does from the backend.

  1. The secret arrives on STDIN, and only there. An argument would be visible in the process list and recorded in the shell history, and no option flag can take that back afterwards. A terminal is refused rather than read: prompting would hang a provisioning script in a way that looks like a freeze.
  2. Re-running replaces, it does not re-issue. When the provider already references a stored credential, the secret is rotated under the existing identifier. Anything already pointing at that identifier — most importantly providers.openai.apiKeyIdentifier in the extension configuration, which the specialized speech and image services read — keeps working. A provider that references an identifier the vault no longer knows is re-stored under that same identifier rather than given a new one.
  3. Both paths write the same provenance. The command records table, field and source with the secret, as the wizard intends to. The wizard passed those keys at the top level of the options array, where nr-vault's store() ignores them — it reads provenance from the metadata key. That is corrected here, so the audit trail no longer depends on which path created the provider.

Consequences 

  • A scripted install provisions a provider end to end without invoking any vault:* command; nr-vault stays nr-llm's implementation detail rather than a consumer's setup step.
  • The command is registered with schedulable: false. It reads STDIN, which the scheduler cannot supply.
  • Secrets stored by the wizard before this change carry no provenance metadata. Nothing reads that metadata for behaviour, so no migration is needed; older secrets simply stay unlabelled until they are next replaced.
  • The provider record still has to exist first. Creating providers from the command line is a separate concern and is not addressed here.

ADR-125: Per-adapter collaborator classes 

Status

Accepted

Date

2026-08-04

Context 

Every provider adapter keeps its private helpers inline; shared behaviour has so far moved up into AbstractProvider or sideways into traits ( ResponseParserTrait , ErrorMessageSanitizerTrait ). There was no precedent for a class that belongs to exactly one adapter.

OpenRouterProvider carried one cluster that neither direction fits: the model routing — six methods, 163 contiguous lines, pure decision logic with no HTTP, no PSR-7 and no response objects. It is also the part most likely to grow, because routing strategies are the product's premise. Pulled up it would burden six adapters that route nothing; as a trait it would stay untestable in isolation and keep the adapter's line count.

Decision 

Adapter-specific logic that is pure — no transport, no credential path, no response parsing — MAY be extracted into a collaborator class in a sub-namespace named after the adapter (here \Netresearch\NrLlm\Provider\OpenRouter\ModelRouter ). Rules:

  1. The provider constructs it inline. The adapter constructor signature is owned by AbstractProvider , shared by all seven adapters and wired by the compiler pass; a collaborator is an implementation detail and never a DI service of its own.
  2. The collaborator is stateless. Configuration the adapter's configure() owns and validates (here the routing strategy) stays on the adapter and arrives per call. Runtime caches (here the fetched model catalogue) stay on the adapter and arrive as an argument — as a closure where the inline code fetched lazily, so extraction cannot introduce a network call that was not there before.
  3. The architecture guard covers the sub-namespace. The PHPat rule that keeps Service\* off concrete adapters matches classes ending in Provider; a collaborator named anything else would quietly escape it. Each new adapter sub-namespace is added to the deny set in the same change that creates it.
  4. Transport is out of scope. Anything touching the HTTP client, auth headers, retries or error mapping stays on the adapter, where AbstractProvider 's vault client and SSRF gating are unavoidable.

Consequences 

  • OpenRouterProvider drops from 983 to 829 lines and the routing becomes directly unit-testable; the existing  22 routing tests keep passing unchanged because they assert through the captured request body.
  • The first collaborator sets the naming and placement pattern for any future one (e.g. a Gemini message converter), each requiring the same guard extension.
  • The golden-sample note in AGENTS.md still points at OpenAiProvider — the inline shape remains the default; extraction is for clusters that meet the purity bar above, not a target size.

ADR-126: A named JSON-Schema subset, enforced strict 

Status

Accepted

Date

2026-08-05

Context 

ADR-082 shipped structured completions with a three-keyword structural matcher — type, required, properties — and deliberately excluded enum, pattern, oneOf and the rest: a full validator would add a runtime dependency. The matcher is fail-open: unknown keywords are silently ignored, an empty schema accepts everything.

That posture is correct for the paths the matcher also guards — the ADR-105 tool-input gate and the resume paths depend on its exact semantics — but wrong for structured completions, where a caller who writes enum deserves either enforcement or an error, never silent acceptance. The roadmap asked for "enum, pattern, oneOf, full draft support".

Decision 

The validator gains a second, STRICT mode next to the untouched lenient one. Its contract is a named subset, owned by StrictSchemaSubset :

  • Enforced: type (incl. union arrays), enum, const, pattern, string lengths, numeric bounds (incl. numeric exclusive*), integer multipleOf, items/prefixItems and the array assertions, properties/required/additionalProperties (bool and schema forms), oneOf/anyOf/allOf/not.
  • Annotations accepted and ignored: description, title, default, examples, format (annotation-only in 2020-12), $schema, $id.
  • Everything else — notably $ref/$defs — is out of subset, and out of subset is fail-closed: the schema is rejected as a whole, so the subset is enforceable rather than aspirational. "Full draft support" is deliberately not built; reference resolution is the point at which a subset becomes a JSON-Schema implementation, and ADR-082's no-runtime-dependency decision stands.

completeStructured() pre-flights the schema against the subset and throws (code 1784500003) before the first provider call: an out-of-subset schema fails for every possible response, and discovering that after the repair round-trip would cost two paid requests.

The lenient mode is byte-identical to before. The two modes are tied together by an invariant a fuzzy test pins: anything strict accepts, lenient accepts. Strict only ever rejects more, which is what makes it safe to introduce next to a mode that guards a security boundary.

Named deviations from JSON Schema 2020-12 

Each deliberate, none silent:

  1. pattern is compiled as PCRE (delimiters added, u modifier), not ECMA-262. A non-compiling pattern is out of subset; a match aborted by the backtracking limit rejects — fail-closed either way.
  2. multipleOf is honoured for positive integers only. Float steps need epsilon arithmetic that quietly lies about conformance (fmod(0.3, 0.1) ≠ 0).
  3. 5.0 is not an integer. Primitive matching byte-mirrors the lenient matcher's PHP-native checks — the price of the strict-implies-lenient invariant, and cheaper than two subtly different type systems.
  4. String lengths count code points (mb_strlen).
  5. enum/const/uniqueItems compare JSON values: maps key-order- insensitive, lists order-sensitive, scalars with === — so 1 and "1" differ, and 1 vs 1.0 differ although JSON has one number type. {} and [] decode identically in PHP and compare equal; the ambiguity is the same one the lenient matcher documents.

Consequences 

  • External callers of CompletionServiceInterface::completeStructured() whose schemas carry out-of-subset keywords ($schema and friends are fine — they are annotations) now get an immediate typed error instead of silent partial validation. Breaking, recorded in the CHANGELOG; there are no in-repo callers.
  • The ADR-105 input gate, the resume paths and the evaluation grader keep the lenient mode untouched. Migrating any of them to strict is a separate, deliberate decision with its own compatibility analysis — suspended runs persisted under lenient semantics must never start failing on resume.
  • ADR-082's "no enum/pattern/oneOf" consequence is superseded by this ADR; its no-runtime-dependency decision and the repair round-trip stand.
  • Provider-native structured output (OpenAI json_schema, Gemini responseSchema, Ollama format) was the separate follow-up ADR-082 named; ADR-128 delivered it, with the subset walker as its pre-flight gate.

ADR-127: A marked, versioned API surface 

Status

Accepted

Date

2026-08-05

Context 

The roadmap asks for a "public versioned API surface". The extension already has most of the ingredients: ADR-028/065/101 govern which services are container-public, the Documentation/Api/ pages describe the consumer services, and semantic versioning is practised in releases. What is missing is the identity of the API: nothing in the code says which classes the semver promise covers. A downstream developer whose autocompletion offers AgentRunPersister next to CompletionServiceInterface has no signal that one is a contract and the other an implementation detail that may vanish in a minor release.

Decision 

Every class-level docblock carries one of three markers, and the marker — not the container visibility, not the documentation — is the authority on what semver covers:

  1. ``@api`` — the consumer surface. Calling these is covered by semver: no removal, no signature break, no behavioural contract break within a major version. Membership is the signature-transitive closure of the entry points: every type that appears in an @api method signature is itself @api (the response objects, option classes, value objects and typed exceptions a caller necessarily touches). A hand-curated list inevitably drifts; the closure rule is checkable.
  2. ``@api Extension point`` (the marker's literal casing) — interfaces and attributes third parties implement rather than call (tool, guardrail, provider, translator, search-backend, preset, evaluation and middleware contracts). These carry a stricter promise, forced by the direction of implementation: no new abstract member within a major version, because adding one breaks every existing implementor, not just callers.
  3. ``@internal`` — everything else, explicitly. Controllers, widgets, hooks, upgrade wizards, commands, DI passes, form elements, repositories and the setup wizard may change without notice in any release.

What this deliberately is not 

  • Not a phpat rule. phpat selects by namespace and inheritance, not by docblock tag, and cannot assert "signature types of @api methods are @api". The closure property is instead asserted by the API snapshot test (follow-up to this ADR): the snapshot renders every @api signature, so an out-of-closure type surfaces as an unmarked name in a rendered signature.
  • Not a change to container visibility. public: true in Services.yaml remains governed by ADR-028/065; ADR-101 remains the count authority. The two sets overlap but are not equal: a service can be container-public for a TCA itemsProcFunc (Category E) and still @internal, and a value object can be @api without being a service at all.
  • Not a compatibility promise for protected members. The promise covers what a consumer calls and what an implementor must provide. Subclassing internals of @api classes is out of contract.

Consequences 

  • 128 classes are @api (85 entry points and hand-verified members plus 43 added by running the closure to its fixpoint), 20 are extension points, and every previously unmarked class in the internal directories carries @internal (82 new markers; the rest existed). IDEs and PHPStan surface @internal usage from outside the package.
  • Documentation/Api/Stability.rst states the promise in consumer terms and is the first page of the API reference.
  • A follow-up PR adds the snapshot test that freezes the rendered @api signatures in-repo, so an unintended break fails CI before review.
  • New code must pick a marker at creation time; the snapshot test's file list makes an unmarked new public-namespace class visible in review.

ADR-128: Provider-native structured output 

Status

Accepted

Date

2026-08-05

Context 

ADR-082 built structured completions on a prompt instruction plus local validation and one repair round-trip, and named native, per-provider enforcement as a follow-up behind a schema-compatibility normaliser. ADR-126 delivered the missing precondition: a named, pre-flighted schema subset. What remained was the transport — the recon found that only the OpenAI adapter emitted any response_format at all; the other six adapters silently dropped the option, so even plain JSON mode was prompt-only on six of seven providers.

Decision 

ChatOptions gains withResponseSchema(array) (declared outside the constructor, like suppressRequestCount — the @phpstan-consistent-constructor/ToolOptions constraint). Both completeStructured*() methods attach the already-pre-flighted schema; it reaches the adapter as response_schema in the flat options array. Each adapter emits its provider's dialect:

  • OpenAI, Groq, Mistral, OpenRouter share OpenAiResponseFormatTrait. A schema that qualifies for OpenAI's strict mode (conservative profile: object root, every object additionalProperties: false with all properties required, allowlisted keywords only) is sent as `response_format: {type: json_schema, strict: true}``; any other schema degrades to ``{type: json_object}` — strict mode 400s on schemas outside its rules, and a provider error for a valid ADR-126 schema is the failure mode this profile exists to prevent. Plain response_format: 'json' now emits JSON mode on all four (previously: OpenAI only).
  • Gemini sets generationConfig.responseMimeType: application/json and, when the root is expressible, responseSchema in Gemini's dialect. The dialect conversion only ever widens (drops keywords it cannot express — a partially-converted enum would narrow below the real schema and block valid values, so inexpressible keywords are dropped whole).
  • Ollama sends the schema verbatim as the top-level format field (like think), or format: 'json' for plain JSON mode.
  • Claude has no response-format parameter; the native idiom is a single forced tool whose input_schema is the schema, and whose tool_use input is returned as the JSON string every other provider returns. Object-root schemas only (Claude's requirement); anything else stays prompt-only.

The invariant that makes all of this safe: native enforcement narrows what the model can emit; the local strict validation (ADR-126) remains authoritative on the response. Native emission may be weaker than the schema (Gemini dialect, json_object fallback) but must never be stronger; the prompt instruction and the repair round-trip stay untouched.

Consequences 

  • completeJson() now gets real JSON mode on Groq, Mistral, OpenRouter, Ollama and Gemini instead of relying on the prompt alone — fewer "Failed to decode JSON response" failures, no API change.
  • Streaming is deliberately out of scope: structured completions never stream (ADR-062), so streamChatCompletion() does not emit schemas.
  • The strict-mode profile and the Gemini dialect are conservative by design; widening them (more keywords, union types) is additive and needs no API change.
  • Ollama receives the full subset schema; its grammar conversion handles the ADR-126 keyword set, but an exotic schema a future Ollama version rejects would surface as a provider error — accepted, since Ollama is the local reference provider and the schema was pre-flighted.
  • On Claude, a structured completion's finishReason reflects the forced tool call (tool_calls) rather than stop; callers of completeStructured*() consume the decoded array, not the reason.
  • ADR-082's "no native parameter at all" statement about Claude is superseded by the tool-forcing idiom; its prompt+repair architecture stands.

ADR-129: In-repo consumers of structured output 

Status

Accepted

Date

2026-08-05

Context 

ADR-126/128 built strict, provider-enforced structured completions — and a recon showed that nothing in the repository used them. The wizard ( WizardGeneratorService ) bypassed CompletionService entirely, calling chatWithConfiguration() with a hand-rolled three-stage JSON parse cascade; TaskExecutionService never told the provider about a task's output_format at all (a JSON task's content was JSON only if the model felt like it); the LLM judge grader prompt-begged for JSON and json_decode-d bare.

Decision 

Three consumers move onto the structured pipeline:

  1. WizardGeneratorService calls completeStructuredForConfiguration() with strict-subset schemas for its three shapes; the system prompt travels via ChatOptions::withSystemPrompt() (the manager turns it into a real system message). The parse cascade is deleted. The normalize*() coercion and the fallback-on-failure contract stay — with one deliberate exception: a BudgetExceededException is rethrown, because a budget denial disguised as "the LLM produced nothing" is a mis-diagnosis, not a fallback.
  2. TaskExecutionService requests JSON mode when the task's output_format is json — as ['response_format' => 'json'] option overrides on the configured path, as ChatOptions on the default path — and appends a JSON instruction to the prompt. The instruction is load-bearing twice: user-authored prompt_templates cannot guarantee the word "json" that OpenAI-dialect JSON mode requires.
  3. LlmJudgeGrader grades via completeStructured() with a two-field verdict schema.

Two schema-design rules, both consequences of the adversarial review:

  • No numeric bounds in consumer schemas. minimum/maximum are outside the OpenAI strict-mode profile (the schema would silently degrade to plain JSON mode), and a bound violation would spend a paid repair round-trip only to end in a fallback — strictly worse than the existing normalize*()/clamp semantics, which remain the authority on ranges. A judge scoring "8.5" still clamps to 1.0 instead of failing the grading.
  • All properties required, ``additionalProperties: false`` on every object, empty strings valid. That is exactly the OpenAI strict-mode qualification; on the other providers the same schema is enforced through their native mechanisms (ADR-128).

CompletionService::decodeAndValidate() additionally strips one wrapping Markdown fence before giving up: a model routed through a provider that ignores JSON mode (OpenRouter third-party routing) occasionally fences despite the instruction, and the strip saves the paid repair round-trip. The strict validation still decides.

Consequences 

  • Wizard calls now run through completeForConfiguration() and therefore gain two behaviours they previously lacked: the generation configuration's skills are injected into the prompt, and the budget middleware sees the call (with the rethrow above making a denial visible instead of silent).
  • Wizard requests carry the schema JSON in the prompt (more input tokens) and can spend one repair round-trip where the old cascade did a single call; in exchange the response shape is enforced natively on all seven providers and validated strictly instead of being scraped out of prose.
  • A JSON task on OpenAI-dialect providers, Gemini, Ollama now returns machine-parseable content by construction. The task result is still passed through as text — parsing stays the consumer's job.
  • ConfigurationGenerator (setup wizard) deliberately stays on its raw HTTP path: it runs before any configuration exists, outside the DI-managed provider stack (own SSRF gate and vault audit), and is not a candidate for this pipeline.

ADR-130: Capability grants for backend users 

Status

Accepted

Date

2026-08-06

Context 

nr_llm is admin-only end to end: every backend module is `access => admin``, every AJAX action carries ``denyNonAdmin(), half the builtin tools require an administrator. :ref:ADR-117 <adr-117>` withdrew the first attempt at finer permissions (capability checkboxes) on three verified findings — no chokepoint for streaming, a polarity that turned enforcement into a bypass on user-less paths, and inertness inside an admin-only UI — but left one door open, verbatim: should per-capability permissions become worth it, "they should be designed onto AiActorContext — which already carries the acting identity across the synchronous, queued and service-account paths".

The product decision has now been made: a grant-set (not a role ladder), nothing without an explicit grant, approval as its own grant outside any preset, two coarse task grants, and a non-admin editing surface as a separate follow-up milestone.

Decision 

Grants, not roles. BackendUserGrant is a string-backed enum mirroring ServiceAccountScope : each case documents exactly one enforcement point, there is no wildcard, and a case is only added together with its consumer — a grant nothing reads is worse than none. Roles are documentation-level presets (named grant bundles), not code.

Assignment via TYPO3's own mechanism. Grants are registered as customPermOptions and assigned per backend group in the be_groups access lists; enforcement reads check('custom_options', …) on the live user. The core check short-circuits for administrators, so "admins hold every grant implicitly" comes from the platform, not from our code. A grant is revoked by unticking it — effective with the next request, since every check reads the live group data.

Frozen into the actor at the boundary. currentActor() captures the grant set next to the admin flag and group ids; downstream consumers read the actor, never the ambient user. AiActorContext::hasGrant() is fail-closed: false for service accounts (their mechanism stays scopes) and for anonymous callers.

The two initial grants:

  • tasks_use — execute an existing task and refresh its input data (the two TaskExecutionController AJAX actions). The per-user budget pre-flight (audit REC 4) bounds what a grant holder can spend.
  • agent_approve — decide OTHER users' suspended runs, as a new branch in AiActorContext::mayActOnRun() ; the human sibling of the agent:approve service-account scope. Deliberately the only scope with a grant equivalent — everything else stays owner-or-admin.

Named constraints, each verified against the code 

  1. No colons in grant values. TYPO3 strips :|, from custom permission item keys when rendering the be_groups select (TcaItemsProcessorFunctions::populateCustomPermissionOptions()); a colon-namespaced value would be stored mangled and every check would silently deny. The values are therefore underscore-separated (tasks_use), breaking the naming symmetry with ServiceAccountScope (agent:approve) on purpose.
  2. The AJAX endpoints go live immediately. AJAX routes bypass the module access check (that is why denyNonAdmin() exists, ADR-037). Swapping the two task-execution gates to tasks_use makes them reachable for grant holders now, without any UI — the intended end semantics, bounded by the per-user budget. This ADR must not be read as "inert until the editing module ships".
  3. ``agent_approve`` is doubly unreachable for non-admins today — both human approval surfaces sit behind admin gates (the AgentRun module's access => admin, the Playground's denyNonAdmin()). The mayActOnRun() branch is real, tested code, but only becomes exercisable for non-admins with the editing module. Stated here so nobody reads it as an already-active control.
  4. ``tasks_manage`` does not exist yet. The list/wizard actions have no per-action gate to migrate (they are module-gated only), and the trait's JSON 403 body is the wrong shape for HTML module actions. The grant arrives together with its consumer in the editing-module milestone.
  5. The record picker stays admin-only. TaskRecordsController reads arbitrary table rows with only a housekeeping-prefix exclusion — no tables_select check, no denylist for be_users/sys_log/ vault tables. Opening it to tasks_use without a read-boundary would be a data-exfiltration primitive; the denylist (modelled on the tool denylist) is a prerequisite the editing-module milestone owns.
  6. Grants are group-scoped. custom_options is a be_groups field; a user without groups cannot hold a grant.
  7. The ADR-117 findings, answered: the chokepoints here are concrete controller gates and mayActOnRun() (not a pipeline that streaming bypasses); the polarity is deny-on-absence everywhere, including user-less paths (hasGrant() is false for service accounts and anonymous callers, so the queue worker cannot flip a decision); and the inertness concern is exactly why the editing surface is a committed follow-up rather than an afterthought.

Consequences 

  • AiActorContext::backendUser() gains an optional $grants parameter and the context serialises/rehydrates the grant set with the same fail-closed tryFrom filtering as scopes (recorded in the API snapshot; CHANGELOG entry per the 0.x rules).
  • The resume path still reconstructs the run owner without grants (ResumeCoordinator) — harmless today because nothing on that path reads them; anything that ever does will see the fail-closed empty set.
  • Recommended presets (documentation, not code): AI editor = tasks_use; approval is granted separately and deliberately sits in no preset.

ADR-131: The editor-facing module 

Status

Accepted

Date

2026-08-06

Context 

ADR-130 shipped capability grants, and its own constraints list said why they were not yet observable: every nr_llm module is access => admin, so a gate administrators bypass is inert inside the admin UI (ADR-117's third finding). The committed follow-up is a surface non-admins can actually reach.

Decision 

One new backend module, nrllm_aitasks ("AI Tasks"), containing exactly what the editing role holds: run a prepared task, and decide agent runs waiting for approval. Everything else — configuration, providers, models, playground, wizards, analytics — stays admin-only.

Four structural choices, each forced by a verified platform constraint:

  1. Top-level in the ``web`` group, not a child of ``nrllm``. The module menu drops every top-level module whose own access check fails; since nrllm is admin-only, any child would be invisible to non-admins no matter its own access. Editors work in web, so that is where their module lives.
  2. ``access => 'user'``, never ``'user,group'``. TYPO3 v14 resolves the access string through a gate registry that knows only user, admin and systemMaintainer — an unknown string denies everyone, administrators included. 'user' means the module must be ticked in the be_groups module list; the ADR-130 grants are checked per action on top. Two switches, both required, both documented.
  3. The approvals inbox is the SAME controller, not a copy. AgentRunController (stale-review digest, schema coercion, the exception map) is registered in both modules. Visibility is actor-scoped in one place: an admin or an agent_approve holder sees every run, everyone else only their own (a new optional beUser filter on the run queries). The list filter is a viewport — the write side stays independently authorised per run by AiActorContext::mayActOnRun() .
  4. The task surface is a NEW slim controller ( AiTaskController ), because the admin execute form cannot be reused as-is: it renders FormEngine edit links for four tables an editor cannot touch, and it embeds the database record picker. The editor templates share the data-task-execute contract, so TaskExecute.js is reused unchanged (its element lookups are null-safe where the picker is absent).

What stays out, and why 

  • Tasks with the ``table`` input type are filtered from the editor list (and their execute form redirects like an unknown uid, so a guessed id leaks nothing). The record-picker endpoints read arbitrary tables with only housekeeping exclusions — no tables_select check, no sensitive-table denylist — and stay admin-only (ADR-130). Wiring TableReadAccessService into the picker would also restrict administrators, which is its own decision, not a side effect of this module.
  • ``tasks_manage`` still does not exist. This module adds no management surface, so the grant would still have no enforcement point.
  • "Own runs" for editors is mostly the approver's view. Task executions do not create agent runs (they are usage rows); agent runs are currently started from admin surfaces. The ownership filter matters the moment any non-admin path starts runs, and costs nothing now.

Consequences 

  • Both ADR-130 grants become observable: tasks_use through the task list/run form, agent_approve through the shared approvals inbox (previously doubly unreachable for non-admins).
  • The inbox infobox no longer claims to be admin-only; it states the actual visibility rule for the current actor.
  • Admin discovery: the overview's Tasks card links to the editor module.
  • The gate primitive for HTML module actions ( denyWithoutGrantHtml() ) complements the AJAX JSON variant from ADR-130.

ADR-132: Fail-closed approval audit and turn binding 

Status

Accepted

Date

2026-08-09

Supersedes

the stale-review binding of ADR-109

Context 

Two defects sat on the same code path, ResumeCoordinator::approve() .

The decision that authorises a write was not audited. AgentRunPersister::recordApproval() caught every Throwable , logged a warning and returned void . The coordinator called it unchecked and continued into the execution. A destructive tool call could therefore be approved and executed with nothing anywhere recording who approved it. The write step audit has been fail-closed since ADR-111 AuditPersistenceFailedException fails a run whose WRITING tool executed but whose step could not be stored. The decision that authorised the write was not covered by anything.

The reviewed turn was bound on one surface only. The stale-review digest introduced with ADR-109 lived in AgentRunController::approveAction() . The Tool Playground's awaiting_approval payload returned no digest and its resume endpoint read only runUuid and approve — so the playground could approve a turn it had never displayed. Worse, the module's own check ran before the atomic claim, against the row as it was then.

Decision 

One digest definition. The computation moved out of the inbox view factory into PendingTurnDigest , a small @internal service both the rendering side and the verifying side use. A digest is a comparison; two implementations that drift apart silently compare different things. The hash covers the pending calls only — the transcript and the counters change every round and would make every digest stale.

The digest travels with the decision. ApprovalDecision carries a third property, turnDigest. Both surfaces hand it through; neither compares anything. The comparison happens once, inside the coordinator.

The verified state is the state loaded after the claim. Losing the claim race does not leave the run untouched: the winner runs the turn, the loop continues, and the run can suspend again on a different turn — which is exactly the row the next claim succeeds on. The pre-claim read would be the previous turn, so the digest check, the write classification and the execution all read the freshly claimed row.

The state is nevertheless decoded twice, and the two decodes answer different questions. The pre-claim decode asks "was this row readable when we found it" and refuses without claiming — a row corrupted outside the extension stays WAITING_FOR_APPROVAL with its blob intact, so an operator can inspect and repair it. The post-claim decode asks "is the row we actually won readable" and must settle the run on a no, because the claim is already held and an unreadable state cannot be written back. Both are needed. Dropping the pre-claim one would make the ordinary corrupt-row case terminal on the first Approve click, and the guarded terminal settle clears suspended_state — destroying the evidence along with the run, the outcome AgentRunExecutor already names as the one to avoid. Dropping the post-claim one would let the race resume a turn nobody verified.

Two gates between the claim and the execution.

  1. The decision must name the turn it was made on. A null digest and a mismatching digest both mean "the reviewed turn is not known", so both are refused; the comparison is hash_equals() .
  2. An approval whose APPROVAL event could not be stored may not execute a turn that declares a write. "Declares a write" is ToolEffectResolver::effectFor() (ADR-111), which already resolves an unknown name to NON_IDEMPOTENT_WRITE; a pending entry too corrupt to yield a call at all counts as a write too.

A refused decision releases the run. Both gates suspend the run back to WAITING_FOR_APPROVAL — the existing RUNNING → WAITING transition, which clears the claim and the lease and writes the state back. Nothing executed and nothing settled, so the operator re-reviews the current turn and decides again. A release that itself fails settles the run instead, because a run left RUNNING with no worker is invisible to the inbox and to the reaper alike.

What this deliberately is not 

  • Not fail-closed for read-only turns. A read-only turn whose decision could not be stored continues, with the failure logged. The audit gap is real, but nothing changes state, and refusing would strand a harmless run behind an unavailable audit store.
  • Not fail-closed for a denial. A denial passes gate 1 — deciding on a turn nobody reviewed is as wrong when the answer is "no" — but not gate 2. Gate 2 exists to stop an unaudited write from executing, and a denial executes nothing. Refusing it would leave the write-declaring turn pending and approvable while the operator who wanted it gone is turned away. "Who denied" is still lost, which is why the failure is logged rather than silent.
  • Not a new repository method. The release reuses AgentRunPersister::suspend() . The transition it needs already exists.
  • Not a per-call verdict. One decision still covers the whole pending turn; the digest binds the turn, not individual calls.

Consequences 

  • AgentRunPersister::recordApproval() returns bool , the same shape as recordStep() . The class is no longer uniformly "fail-soft": it never throws, but two methods hand the caller the evidence to fail closed.
  • Two new request-validation exceptions, StaleApprovalTurnException and ApprovalNotAuditableException , join the AgentRuntimeException family. Both surfaces map them: the module to the existing runs.error.staleReview and a new runs.error.notAuditable flash, the playground to a 409 and a 503 that both re-signal awaiting_approval.
  • ApprovalDecision 's constructor gained a third argument. It is optional in the signature for source compatibility only — a null is refused at runtime — so a third party constructing the decision itself must supply the digest of the turn it displayed.
  • The playground carries turnDigest in both its batch pause payload and its streamed awaiting_approval event. It ships no approval UI of its own today, so no client code consumes it yet; the value is there for the first one that does.
  • The controller-side stale check in AgentRunController is gone. One definition of the invariant, in the one place that holds the claim. Its unreadable-state pre-filter is gone with it, but the operator sees the same runs.unreadable flash: the coordinator's pre-claim decode throws CorruptSuspendedStateException , which that action already maps. The Tool Playground, which never had such a pre-filter, gains the guard for the first time.

ADR-133: An approver may only release a write they could run 

Status

Accepted

Date

2026-08-09

Context 

A resumed run executes under the RUN OWNER's identity, deliberately (ADR-083): the queued work acts for whoever started it, not for whoever happened to press Approve. The approver, however, was never checked against the tool they were releasing.

AiActorContext::mayActOnRun() grants the DECISION on the BackendUserGrant::AGENT_APPROVE grant alone (ADR-130) — a non-admin who holds it may decide other users' suspended runs. Combine the two and a non-admin can release an admin-only write tool, which then executes with the owner's privileges. That is a confused deputy: the authority that runs the call is not the authority that authorised it, and nothing compared the two.

The suspension is worth exactly as much as the check behind it. An approval gate that any grant holder can satisfy for any tool is a queue, not a gate.

Decision 

The approver passes the same gate the execution would. ResumeCoordinator::approve() resolves the APPROVER's live backend user — the ActingBackendUserResolver of ADR-083, applied to the deciding identity rather than the executing one — and asks ToolCallPolicy::decide() about every pending call that DECLARES a write ( ToolEffectResolver , ADR-111). A denial refuses the release.

Execution identity is unchanged. ADR-083 stands: the turn still runs as the run owner. This adds a second, independent condition on the DECISION; it does not move the identity the tools authorise against, and a unit test asserts that the actor reaching the tool loop is still the owner's.

Read-only calls are not checked. The gate exists because a write executes on someone else's authority. A read-only pending call changes nothing, and the owner's own gate still decides what actually runs when the turn resumes.

A service account may not release a write-declaring turn. Its authority is scopes, not backend permissions: AiActorContext::hasGrant() returns false for it by construction and it carries no backend-user uid. decide() with $user === null therefore checks only enabled / configuration group / trust zone — the admin axis bites solely on requiresAdmin() , so a write tool without that flag would pass a gate that is effectively absent while a human is checked properly. Refusing is the only variant that stays fail-closed without inventing a second authorisation axis for service accounts. A service account may still release a read-only turn, so an automation that clears harmless pauses keeps working.

A human whose uid no longer resolves is refused too — a deleted or disabled account has no live permission surface to check, and "no user" is not "permitted".

Placement: after the turn binding, before the audit write. The gate is the second of the three between the claim and the execution (ADR-132 owns the first and the third):

  1. the decision must name the turn it was made on;
  2. the approver must be permitted to run every write it releases;
  3. an approval that authorises a write and could not be recorded does not execute.

After (1), because it judges the calls of the turn that was actually reviewed — and, like everything since ADR-132, it reads the state loaded AFTER the claim, never the pre-claim copy, which may be the previous turn. Before (3), because a refused approval must not enter the audit stream as a decision that stood; that is the same rule gate 1 already follows.

A refusal releases the run. The existing release() helper hands the run back to WAITING_FOR_APPROVAL: nothing executed, nothing settled, and somebody who does hold the permission can still decide the turn. The refusal is logged with the actor, the tool and the policy's reason, and both surfaces map ApproverNotPermittedException — the module to a flash, the playground to a 403 that re-signals awaiting_approval.

What this deliberately is not 

  • Not a new grant. ADR-130 admits a grant only together with its consumer, and this needs none: the tool gate and the backend user's own admin flag already carry the answer.
  • Not a change to who may decide. mayActOnRun() is untouched. A grant holder still reaches every suspended run; they are simply refused on the writes they could not run themselves.
  • Not applied to a denial. The same asymmetry ADR-132 states for the audit gate, for the same reason: the gate stops an unauthorised WRITE from executing, and a denial executes nothing. Refusing it would only leave the write pending and approvable while the operator who wanted it gone is turned away.
  • Not a scope for read-only exposure. A non-admin approver can still release a read-only call they could not run themselves, which lets its result into the run's transcript. That is a smaller and different problem — the approver cannot read another user's run events (AGENT_READ has no grant equivalent) — and widening the gate would refuse every read-only pause a non-admin approves. Stated here so the limit is a decision, not an oversight.
  • Not a per-call verdict. One decision still covers the whole turn; the gate refuses the turn when any write call in it fails.

Consequences 

  • ResumeCoordinator gained three optional collaborators: the tool policy, the acting-user resolver and a logger. The policy is the one that switches the gate on. A null policy does NOT refuse everything — without a gate there is no verdict to fail closed on, and refusing would make the bare positional construction unable to approve anything at all. The arm is unreachable from the container, where ToolCallPolicyInterface is aliased, and AgentRuntime hands its own policy down to the coordinator it builds.
  • One new request-validation exception, ApproverNotPermittedException , joins the AgentRuntimeException family, with a message that names the actor, the tool and the policy reason.
  • No builtin tool declares a write today (ADR-122), so nothing in the shipped catalogue changes behaviour. The gate is in place for the first write tool and for MCP-provided ones, and the tests construct a write-declaring turn to exercise it.

ADR-134: A builtin's declared write effect implies human approval 

Status

Accepted

Date

2026-08-09

Amends

ADR-084 and ADR-105

Authors

Netresearch DTT GmbH

Context 

Two declarations describe a tool that changes something, and until now they were unconnected.

ToolEffectInterface (ADR-111: Tool side effects and fail-closed audit for writes) says what a tool does to the world. It feeds the write fence, the lease, the audit step and the retry decision — four readers, none of them about authorisation.

RequiresApprovalInterface (ADR-084: Human-in-the-loop tool approval with suspend and resume) says a human must approve the call before the loop executes it. It is the only thing the approval scan in ToolLoopService looks for, and no production class implements it: the sole implementers are two anonymous test classes.

A tool could therefore declare NON_IDEMPOTENT_WRITE and still run unattended. The two statements would have to be made in the same commit by someone who knew both existed, and nothing failed if only one of them was.

The effect declaration is the better of the two to key on. It is a property of the code and deliberately not configurable (ADR-111: Tool side effects and fail-closed audit for writes): an administrator cannot relabel a write as a read to dodge the audit, which is exactly the property an authorisation input needs. The marker is an opt-in nobody has yet opted into.

Decision 

A tool counts as approval-bound in the loop's approval scan when it implements RequiresApprovalInterface; when it is a remote tool whose operator declaration says so; or when it implements ToolEffectInterface, its getEffect() is a write, and it is not remote.

Both write cases qualify. isWrite() is the predicate, not idempotency — whether a repeat compounds governs retry, not whether a human should have seen the call in the first place.

Every check is an instanceof or a getter on the tool the scan already fetched. No resolver, no repository, no new dependency on the loop: nothing here needs the registry-wide view ToolEffectResolver exists to provide, and its unknown-tool fallback (NON_IDEMPOTENT_WRITE) would turn every unregistered name into a suspend instead of the refusal the invocation path already gives it.

The pre-existing fail-closed rule is unchanged: only an offered tool suspends. A registered-but-not-offered tool named by a model steered through injected prose still falls through to the invocation gate, which refuses it — there is no spurious approval prompt for a tool the run never allowed.

The operator declares it, per server 

tx_nrllm_mcp_server.requires_approval is that column, built like data_class beside it (ADR-094: Tool data classes and provider trust zones): the operator declares it, the server never does, and there is no code here to derive it from.

It differs from data_class in having a default, and defaults to 1 — approval required. A data class has no safe guess, so an undeclared server is inert instead. A yes/no does have one: a server nobody has judged asks first. The reading is fail-closed the whole way down — McpServerRecord::approvalRequired() treats only a literal 0 as "no approval", so a missing column, a NULL, an empty string or a value from a schema this version does not know all come back as "required". The alternative would be a byte this code cannot read letting an unattended remote write through.

The flag reaches the scan on the tool, not through a lookup. McpToolProvider already builds every McpTool from the very server record that carries it, so it is a constructor argument, surfaced by RemoteApprovalInterface::requiresApproval(). The scan runs once per tool call in the loop; giving it a repository would put a query on that path and a persistence dependency into a class that must not know MCP exists.

RemoteApprovalInterface extends RemoteToolInterface deliberately. A free-standing "declare your own approval" interface would quietly make a write-without-approval builtin expressible again, which the last section of this ADR says is not to be. Extending the remote marker means a class cannot reach for the declaration without also claiming that its behaviour lives outside this codebase — the one case in which an operator declaration beats reading the code.

Every server requires approval, existing ones included. The default of 1 lands on every pre-existing row when the schema updates, and nothing corrects it afterwards: there is no upgrade wizard, and the state after an update is the state after a fresh install. The assurance therefore rests on the schema alone, which is what McpServerApprovalDefaultTest pins by dropping the column, writing a row the way the previous version did, and running the add/change migration PackageSetup runs.

A pinned install was the alternative, in the shape of ADR-113: Fail-closed tool data-class enforcement switch/ADR-115: Tool data-class enforcement is the default for new installs: a wizard that writes an explicit 0 on the servers already importing tools, so a new default cannot stop an integration that runs today. It was written and then removed. MCP here is planned and not yet in production use, so the running integrations such a wizard preserves do not exist, and what remained was a fail-open path through the very assurance this decision introduces — one that, matching on the value rather than its origin, could not tell a 1 the schema wrote from a 1 an operator chose, and so would have switched approval off on a server nobody had judged.

Turning the flag off is an operator's decision, taken per server once they know what that server's tools do. It is a tick in the record, not something an upgrade does on their behalf.

Registration bans the implicit combination too 

ToolRegistry already refuses a tool that implements both RequiresApprovalInterface and RequiresInputInterface (ADR-105: Typed user-input suspension (WAITING_FOR_INPUT)): the approval-resume path carries no user input, so the combination is unsupported. This decision makes a declared write a second way to be approval-bound, and the ban therefore extends to it — a non-remote, write-declaring tool may not implement RequiresInputInterface either.

The extension is not defensive tidying. Without it the combination is not "handled by the runtime", it is dead:

  1. The approval scan runs before the input scan, so the tool suspends AWAITING_APPROVAL, never WAITING_FOR_INPUT.
  2. ToolLoopService::resume() refuses an input-requiring pending call ("requires user input that was not provided") — correctly, since the approval path carries no data.
  3. The model re-requests, the approval scan binds again, and the cycle repeats: one operator decision spent per turn and the tool never executes.
  4. submitInput() is unreachable. It requires status WAITING_FOR_INPUT, and the approval suspension's SuspendedRunState carries neither inputToolName nor inputSchema.

The refusal in step 2 is the mechanism of the defect, not its handling: it is what makes the cycle permanent. Nothing in the run reports the cause, and the operator sees only a tool that asks and asks. A registration failure at container boot names it.

What this costs is real and is the cost ADR-105: Typed user-input suspension (WAITING_FOR_INPUT) already accepted: a tool that needs both a human's data and a human's consent cannot be built. The runtime has no combined approval+input pause — ADR-105 banned the combination rather than build one — so allowing the declaration would promise a flow that does not exist.

The registration predicate mirrors the loop's, remote exemption included, so it can never reject a tool the approval scan would have let through. It sits in the constructor, which sees only the compile-time builtins; provider-supplied tools (the remote ones) are exempt from the coupling anyway.

Consequences 

●● A write that ships without the approval marker still pauses for a human. The declaration that already had to be right for the audit and the retry now also carries the authorisation, so the two cannot drift apart.

● Nothing changes for the tools shipped today. Every builtin reads — which ToolEffectCoverageTest pins — so the new branch is inert until the first writer lands. That test stays the builtin list; its scope over ToolRegistry::builtinNames() is untouched, because a provider-supplied tool must not be able to satisfy or break a guarantee about code in this repository.

◐ The reasoning is in one place. A tool author declares an effect for ADR-111: Tool side effects and fail-closed audit for writes's reasons and gets the human-in-the-loop pause without knowing the marker exists.

✕ A builtin can no longer both declare a write and ask the user for typed input. The combination has no working runtime path, so it now fails at registration instead of livelocking at run time — but a genuine case for it has no workaround short of the combined pause below.

◐ A remote tool pauses when an operator says so, and never because of what its effect or its server claims. The judgement an MCP tool cannot supply about itself is made once, per server, by the person who connected it.

✕ An update can stop an MCP integration that ran unattended before. Nothing pins the old behaviour, so an existing server suspends its runs until an operator unticks the box. That is the cost of not having a path that switches approval off on rows it cannot tell apart.

✕ It is a per-server switch, not a per-tool one. A server whose catalogue mixes a search with a delete is approved on the coarser of the two, and the operator's only finer instrument is a second server entry. Per-tool declarations would have to be stored against catalogue rows the import rewrites, and nothing reads them yet.

Revisit when 

A per-tool remote declaration is actually asked for — the coarseness above is a known consequence, not an oversight, and the catalogue table is rewritten on every import, so a per-row flag needs a reconciliation rule before it needs a column.

Also revisit when a tool genuinely needs both a human's data and a human's consent. That needs a combined pause — one suspension that collects the input and the decision together — which ADR-105: Typed user-input suspension (WAITING_FOR_INPUT) deferred. When it exists, the registration ban above drops for tools that use it.

Also revisit if a builtin ever needs to write without a pause. Today that is not expressible, deliberately: the way out is to not declare a write, which the audit and the retry would immediately make wrong. A real case for a write-without-approval builtin is a case for a third declaration, not for loosening this one.

ADR-135: The first writing tool, and the contract it actually needed 

Status

Accepted (its non-guarantee section is closed — see ADR-141)

Date

2026-08-09

Amends

ADR-122 (its premise, not its reasoning)

Amended

2026-08-10 by ADR-141, ADR-146

Authors

Netresearch DTT GmbH

Context 

ADR-122 declined to build a side-effecting tool contract — an ActionInterface, an idempotency scope, a preview — on the grounds that no tool wrote, and said so literally: reconsider "starting from the tool, not from this ADR". This is that tool.

The premise of ADR-122 is what expires here, not its reasoning. Its three observations still hold: the idempotency scope has no reader, the preview has no display, and promoting getEffect() onto ToolInterface would break every third-party tool for no behavioural gain. So the writer arrives as what the other forty-one builtins are — one class implementing ToolInterface, plus the opt-in ToolEffectInterface — and no framework arrives with it.

Decision 

Ship UpdatePageMetadataTool (update_page_metadata): set a fixed allow-list of descriptive fields on exactly ONE page, through the DataHandler, as the acting backend user.

Why this tool and not a generic record editor 

A update_record(table, uid, fields) tool is one tool instead of many, and that is its only advantage. Its blast radius is the whole TCA: the same call shape that fixes a meta description can change a page's slug, its fe_group, its perms_* or a be_users row. The arguments are model-chosen and the model is steerable by injected, externally-authored skill prose (ADR-036), so "the model would not do that" is not a control.

A narrow tool moves the decision from runtime to review time. What update_page_metadata can do wrong is bounded by its allow-list, and the allow-list is in the diff. A second narrow writer is a second small review; a generic writer is a permanent one.

The field allow-list 

title, subtitle, nav_title, abstract, description, keywords — always present — and, when EXT:seo is installed, seo_title, og_title, og_description, twitter_title, twitter_description.

Every entry is a scalar input or text column carrying descriptive prose: no relation, no routing, no visibility, no access control. The static list is intersected with the live TCA, so an install without EXT:seo is never offered a field a call could only fail on.

Excluded, with the reason:

slug, shortcut, url, target, canonical_link
They decide where a URL points. A wrong value breaks routing or sends traffic somewhere else.
doktype, hidden, nav_hide, starttime, endtime, fe_group
Publication and audience. Unpublishing a page is not a metadata edit.
perms_*, editlock, TSconfig, backend_layout*, is_siteroot
Permission and configuration surface — the things the tool is authorised against.
no_index, no_follow
They sit in the SEO palette and read like metadata; a single call can deindex a site.
author, author_email
A claim about a person, plus personal data. A model must not assert authorship.
og_image, twitter_image, media
FAL relations. The DataHandler's relation handling is a different risk class than setting a scalar.
sys_language_uid, l10n_parent, l18n_cfg
Translation topology.

One page per call, live workspace only 

Exactly one uid, so every suspended call has one reviewable subject on the approval card. Everything but workspace 0 is refused: a draft write belongs to the workspace publishing machinery, which carries its own review semantics, and this tool does not silently join them.

An unknown field refuses the WHOLE call rather than applying the known half of it. A record half-written from a call the model got wrong is harder to reason about than a refusal the model can correct.

The write refuses without a backend environment (E2) 

DataHandler declares $GLOBALS['TCA'] and $GLOBALS['LANG'] as its dependencies in the class docblock, and start() sets only DataHandler::$BE_USER — a foreign hook running inside the write still reads $GLOBALS['BE_USER']. On a request-bound run all three exist; in a bare worker process they do not.

The tool refuses and names which one is missing. It does not populate the globals. Establishing an ambient backend user is exactly the thing ADR-083 removed from this runtime, and a tool that sets globals it does not own would be setting them for every hook and every later request in the same process.

Two honest limits of that check. First, for a plain page-field update the core code path does not itself dereference $GLOBALS['LANG'] — the getLanguageService() call sites are the password-policy, copy-prepend, localize and flash-message paths. The prerequisite is the class's declaration and the hook surface, not an observed fatal on this path; the check honours the declaration rather than betting on the current implementation. Second, requiring $GLOBALS['BE_USER'] to exist does not make it the same user as the acting one. A hook that reads the ambient user still reads whoever the request belongs to. Closing that gap means either mutating the global or auditing every hook — both larger than this tool, and neither is done here.

Where the write lands in the existing machinery 

Nothing new was needed:

  • Approval. The declared write effect makes every call suspend for a human (ADR-134). The tool carries no approval marker of its own, which is the coupling working as designed.
  • The audit. AgentRunExecutor::recordStepFailClosedForWrites() fails the run when a writing step's audit event cannot be stored. That guard is outside the lease branch, so it holds on every path.
  • Data class. The new editing group defaults to EDITOR_CONTENT (ADR-094). Without that entry the group would fail closed to SECRET_ADJACENT and the tool would vanish from every external-provider run with no explanation.
  • The coverage test. ToolEffectCoverageTest::DECLARED_WRITERS was empty and is now one entry long. That is the assertion ADR-122 built for this moment.

A group of its own, not content 

editing is new. Putting the writer in content would give write capability to every configuration that already grants the read-only content group — a capability change delivered by an upgrade, in a field nobody edited.

The group gate is not the only layer, and it is not the strongest one: an empty allowed_tool_groups means "no group restriction" (AllowedToolsResolver::applyGroupGate()), so a configuration that never restricted groups sees the new tool regardless of which group it is in. That case is caught by isEnabledByDefault() returning false — the tool is globally off until an admin enables it in the Tools module — and by the approval pause. The group split closes the case the enable flag does not: an operator who deliberately enables the tool for one configuration does not thereby enable it for every configuration that had listed content.

Success is verified, not assumed 

An empty DataHandler::$errorLog is not proof the values landed. The DataHandler silently skips a field the acting user holds no non_exclude_fields grant for — no exception, no log entry. The tool therefore re-reads the row and reports any field whose stored value is not the requested one as an error.

Reporting a write that did not happen is the worst available outcome for a tool whose entire premise is that a human approved a specific change.

The read-back can only report that a field did not arrive, and it names the one cause it cannot rule out. So the second cause is removed before the write instead: an empty value for a field the TCA marks requiredpages.title — is dropped by validateValueForRequired() just as silently, and the argument gate refuses it. Otherwise an admin, who holds every field grant by definition, would be told they were missing one; and where the stored value happened to equal the rejected one, the read-back would report success for a write the DataHandler refused. Clearing an optional field stays available: that write happens, and the read-back verifies it.

Amendment: the second writer, and the language question it raised 

set_file_alternative_text sets sys_file_metadata.alternative for one sys_file uid. It is the trigger this ADR named under "Revisit when", so the answer belongs here rather than in an ADR of its own: nothing below changes a decision above, it applies them to a second table and settles the one question the first tool never had to ask.

What it takes over unchanged 

IDEMPOTENT_WRITE and therefore the approval pause; isEnabledByDefault() false; requiresAdmin() false; the editing group; the live-workspace restriction; the backend-environment refusal; the read-back verification; the ToolPreviewInterface before/after (ADR-136); one neutral refusal string shared with the READ tool of the same records (read_fal_asset_meta), so a refusal never confirms that a uid exists.

Where it differs, and why 

The authorisation axis is FAL, not the page tree. Access is decided by FalStorageGate::isFileAccessible(): the configured storage allow-list, intersected for non-admins with their file mounts. That allow-list is nr_llm's own configuration and the DataHandler has never heard of it, so the gate must run BEFORE the write — not instead of it.

That gate does not decide both halves against the same user, and this tool is the first caller for which the difference is reachable. The storage allow-list is intersected with the explicit acting user's storages (BackendUserAuthentication::getFileStorages()). The file-mount boundary is not: isFileAccessible() asserts on a request-shared ResourceStorage, whose mounts and permissions core's StoragePermissionsAspect attached once from $GLOBALS['BE_USER']. Where ambient user and acting user coincide — a run its own owner approves — nothing differs; on an approval by someone else they do. The defect is in FalStorageGate, shared with three READ tools since ADR-047, and is tracked as issue #672 rather than fixed on this tool.

Core's FileMetadataPermissionsAspect then applies the narrower question inside the DataHandler (a WRITABLE file mount, editMeta), and — this is easy to get backwards — that aspect can only ever DENY: tables_modify for sys_file_metadata still decides first, exactly as it does for the File list module's metadata form.

It never creates a metadata record. A file that carries none is refused. A tool that creates the record it was asked to edit does more than its name says, and an invented record is a record nobody reviewed.

The read-back guard is not reachable on the shipped TCA. Core marks sys_file_metadata.title as an exclude field but not alternative, so the "exclude field" silent drop cannot bite as delivered. The guard stays: the flag is one TCA override away, and the tool must not report a write that did not happen. The functional test reaches the path by setting the flag and rebuilding the compiled schema — mutating $GLOBALS['TCA'] alone no longer changes what the DataHandler asks in TYPO3 v14.

The language question 

sys_file_metadata is language-aware (sys_language_uid / l10n_parent); pages metadata, for the fields the first writer touches, was not a question at all. Three answers were possible: assume a language, take one as an argument, or refuse while translations exist.

Decision: the tool addresses the default language (sys_language_uid = 0) only, takes no language argument, and refuses when the default-language record is absent.

  • Reader and writer must address the same row. Every FAL read path pins sys_language_uid = 0read_fal_asset_meta and search_fal_files — and so does the tool's own previewCall(). A writer that could land elsewhere would produce changes the model cannot read back and the approval card's "before" column could not be trusted to describe the same record. Note that read_fal_asset_meta is admin-only and in the structure group, while this writer is non-admin and in editing: an editor never gets to call it, so for the tool's stated audience the approval card is the channel for the current value.
  • A language argument is a model-chosen argument. It would widen the call surface by one value that decides WHICH record is written, needs its own checkLanguageAccess() per value, and fails quietly when wrong — the write succeeds, on the wrong translation.
  • Refusing while translations exist would be worse than useless. It would disable the tool's main purpose on every multilingual site and would leak the existence of translations through the refusal.
  • The default language is still not assumed to be permitted: checkLanguageAccess(0) is asserted against the acting user, because a backend user can be restricted to languages that exclude it.

Translated alternative texts stay a backend job. Revisit if a writing tool ever needs to address a translation — at that point the language belongs in the argument list of the tool that needs it, not retrofitted into this one.

The duplication this ADR predicted, and what was done with it 

It was real: about three hundred duplicated lines across the two files, which is what SonarCloud's quality gate reported on the second writer's pull request. It was also, on inspection, entirely mechanism — the errands a writing tool runs around its write, not the decisions it makes about the record.

Mechanism was therefore extracted into WritesThroughDataHandlerTrait, a trait in the same directory, following the pattern CollectsEnvironmentTrait and ResolvesLanguageLabelTrait already set — not a base class. A base class would open exactly the inheritance axis this ADR argued against: a common ancestor invites a common policy, and the two tools' policies are not common.

Shared, because it only executes:

  • the backend-environment refusal (which globals the DataHandler declares) and the live-workspace refusal — both describe the PROCESS performing the write, which is why ADR-136 already excludes both from the preview;
  • surfacing a non-empty errorLog as an error, bounded in count and length;
  • narrowing one table's columns out of the untyped $GLOBALS['TCA'];
  • the two preview-formatting helpers (one-line excerpt, quoted or (empty)) and the three constants they need.

Kept per tool, because it decides:

  • The neutral refusal string. Each writer shares it with the READ tool of the same records, so a refusal never confirms that a uid exists. One shared string would break that pairing in both directions.
  • The authorisation, including the language rule above.
  • The field allow-list and every argument-validation message.
  • isEnabledByDefault(), requiresAdmin(), getGroup() and getEffect(). They return the same four values today and are still declared twice on purpose: a third writer may be admin-only or non-idempotent, and a trait answering for it would turn a decision into an inheritance.
  • The read-back. Both tools verify their write; what "it took" means is theirs — a map of fields against a re-read row versus one column of one record.
  • The row lookup. The query tail is identical; which restrictions apply — a deleted page is gone, a sys_file has no enable columns at all — is a decision about what counts as existing. The second writer also looks its metadata row up by file rather than by uid, so it must pin the workspace and an order on top of the language: sys_file_metadata is workspace-aware, and a draft version carries the same file and the same sys_language_uid = 0 as the live row. Core's MetaDataRepository::findByFileUid() pins the same three.

What remains duplicated after the extraction is under a dozen lines per block, mostly signatures and the two declaration methods above. That is the floor this shape has, and buying it down further would mean sharing decisions. .. _adr-135-nonguarantee:

What this does NOT guarantee: the write fence 

Superseded, as of ADR-141. The fence armed only when a lease owner was present: AgentRunExecutor::trace() installed its onBeforeTool hook under $leaseOwner === null || !$handle instanceof AgentRunHandle ? null : …, and the only producer of a lease owner was QueuedRunCoordinator::runQueued().

That method did have in-repo callers — ServiceAgentQueueAgentRunQueuedHandler::__invoke() and the reaper's re-dispatch in CommandReapStaleAgentRunsCommand — so "runQueued() is unreachable" would have been wrong. What had no in-repo caller was AgentRuntimeInterface::enqueue(), and only enqueue() creates the QUEUED row those callers can act on. The reaper could not conjure one either: findStaleRunning selects on lease_expires > 0, which an interactive run did not have.

Every shipped entry point (the Tool Playground batch and streamed runs, the approval and input resumes, the CLI) went through run(), approve() or submitInput(), none of which passed a lease owner — and AgentRunExecutor::executeResume() took no lease-owner parameter at all, so no resume was fenceable however the run was started. Since a write suspends before it runs, the resume is exactly where this tool executes: the one segment that ran side effects was the one segment that could not be fenced.

The conclusion drawn at the time — that an interactive run has no reaper and no retry path, so there is nothing for a fence to prevent — held only for as long as no interactive run could be retried. ADR-141 gives those segments a lease and therefore a reaper, and closes the gap rather than relying on that argument.

Consequences 

●● A model can change what a page says about itself, with a human approving each change and the acting editor's own TYPO3 permissions enforced twice — once by the tool, once by the DataHandler.

● The write path finally runs. The effect declaration, the fail-closed audit and the approval coupling had no exerciser; they have one now, and the functional tests drive them against a real DataHandler.

◐ ADR-122's idempotency scope stays deferred: this tool needs none, its effect being idempotent by construction. Its absence is now an observation rather than a prediction.

◐ The preview did not stay deferred. This ADR argued the approval card already shows the arguments, which for this tool ARE the new values — true, and half the comparison. ADR-136 supersedes that sentence: the tool implements ToolPreviewInterface and the card shows the values the write would REPLACE.

◐ A downstream consumer that calls enqueue() got the fence; every shipped entry point did not. The gap was named above rather than closed, and the note predicted the shape of the fix — "arming the fence on the interactive path, which needs a lease the interactive path does not have". ADR-141 gives it one.

✕ The ambient-user gap in the E2 check stands: a foreign DataHandler hook may still read a $GLOBALS['BE_USER'] that is not the acting user.

Revisit when 

A second writing tool is proposed. One writer is a class; two writers with the same permission pre-check, the same refusal vocabulary and the same read-back verification are a shared base — and at that point the shape of the contract ADR-122 declined to guess at will be visible in the duplication rather than imagined.

That happened: set_file_alternative_text is the second writer, and the amendment above records what it reused, where it had to differ, and which mechanism moved into WritesThroughDataHandlerTrait. The next trigger is the third writer — and the question then is whether anything the trait deliberately left per tool has become common, not whether the trait should grow.

That third trigger fired too: ADR-146 adds three more writers and answers the question with five implementations in hand. One thing became common — a deleted-restricted row lookup, written four times — and it is a query rather than a decision, so the trait does not grow. The prediction in this record that a later writer "may be non-idempotent" is now an observation: two of the three declare NON_IDEMPOTENT_WRITE.

That second trigger — "revisit if an interactive run ever gains a retry path, because the fence's absence is correct only while interactive means no repeat without a human" — fired: an abandoned interactive segment is now reapable, so the absence stopped being correct. ADR-141 is the answer.

ADR-136: The write preview is produced when the run suspends 

Status

Accepted

Date

2026-08-09

Amends

ADR-122 (the deferred preview)

Authors

Netresearch DTT GmbH

Context 

ADR-122 deferred the preview on two grounds, and both are about placement rather than value:

"The preview has no caller and no display. The one surface that could show it is the approval card […] It would also have to run inside the reviewing administrator's request rather than the run's actor context, which adr-083 forbids reading around."

Producing the preview at suspend time answers both in one move. The caller is ToolLoopService, at the point it throws ToolApprovalRequiredException. The display is the approval card, which by then is guaranteed to exist — the pause is what creates it. And the context is the run's own actor context, because the loop is still executing the run: no administrator's request is involved, nothing is read around ADR-083.

The second half of the reason is what ADR-135 got half right. It observed that update_page_metadata's arguments ARE the new values, so the card already shows them. What the card does not show is what those values REPLACE. "Set the description to X" and "replace this hand-written description with X" are different decisions, and only the second one is a decision.

Decision 

An opt-in ToolPreviewInterface — no base class, no ActionInterface, the same marker shape ToolEffectInterface and RequiresApprovalInterface already have. A tool that implements it returns human-readable lines describing what the pending call would do, and answers whether a given viewer may be shown them; the forty-odd read-only builtins are untouched and render no preview line at all.

The lines are produced inside the approval scan, for every OFFERED call of the suspending turn, and travel with the state:

  • Persisted as an additional optional field on SuspendedRunState. It is inside the same blob as the transcript, so it is encrypted at rest by the same codec (ADR-114) with no new plumbing.
  • Degrading, not failing. fromArray() treats a missing or malformed callPreviews as "no preview". A running installation has suspended runs in its database; every one of them must still resume, and it does — the card falls back to the arguments alone, exactly as before this ADR.
  • Index-bound to its call, across rehydration. A preview records the position of the call it describes. Rehydration drops pending-call entries that are not usable at all, which renumbers the rest, so the stored positions are translated onto the surviving list and a preview whose call did not survive is dropped. Without that translation a corrupt blob would move a preview one call along — silently, plausibly, and past the tool-name guard whenever the turn calls the same tool twice.
  • Rendered in WaitingRunViewFactory::buildApproval(), in the Fluid partial, and in BOTH playground responses (the JSON payload and the streamed awaiting_approval event) through the single pendingTools() helper the two share.
  • Bounded before it is persisted: twenty lines, 500 characters each, whitespace collapsed. A preview is model-triggered output like a tool result, and it goes into an encrypted column that is re-read on every resume.

A failed preview is a line, never a blank and never a fatal 

A tool whose preview throws must not kill the run — the loop catches Throwable, logs it, and stores a line saying the preview failed, marked with a failed flag the card renders as "No preview — you are deciding without one". An empty return is treated the same way.

The alternative — swallow the failure and show nothing — is the dangerous one: an approver cannot tell a tool that has no preview from a tool whose preview broke, and would take a missing warning for the absence of anything to warn about.

The exception TEXT is deliberately not shown, only its class. This follows ToolLoopService::invoke(), which withholds exception bodies for the same reason: a DBAL failure carries Access denied for user X@host, and the preview is persisted and rendered rather than discarded. The full exception goes to the log, where the operator can already read credentials they own.

What if the target changed between preview and execution 

The preview is a snapshot of the pause, not a precondition for the write. A target that changed in between does not block the approval. The card says so, in as many words: "Captured when the run paused — the target may have changed since."

The reasoning, in the order it decided the question:

A writing tool here sets absolute values. update_page_metadata writes description = "…", not "append" or "increment". A concurrent human edit therefore changes what the approver READ, never what the approval DOES. The write that executes is the write that was shown; only its "before" column has aged.

A resource fence has no repair path. Refusing the approval on a changed row leaves the run suspended with no way forward: the model cannot re-issue the call (it is not running), and the approver cannot edit the pending arguments. Every unrelated edit to a busy page — by an editor who never heard of the run — would dead-end an approval that a human had already decided was correct.

The turn digest is not this. ADR-132 binds a decision to the tool CALL that was reviewed, so a stale tab cannot authorise a turn nobody looked at. That is a guarantee about the agent's proposal, and it is exact because the loop owns both sides of it. The target resource is owned by TYPO3 and edited by people; binding to it would be a different guarantee with a different failure mode, and extending the digest to cover it would silently convert ADR-132's precise check into a lossy one.

TYPO3 has no such fence either. Two editors on the same page in the backend overwrite each other, last write wins. A tool that a human explicitly approved is not the place to invent an optimistic-locking regime the rest of the system does not have.

Revisit when a writing tool writes a RELATIVE change. An append, an increment, a "remove the third paragraph" — for those, the "before" is not decoration, it is an operand, and a snapshot stops being sufficient. That tool needs a precondition token; this one does not, and building the token first would be ADR-122's mistake repeated.

Who may read a preview 

The preview is produced under the RUN OWNER's authority and read by the APPROVER, and those are not always the same person. It is therefore authorised twice over, once on each side:

  1. At production. The tool checks the EXPLICIT acting user of the run (ADR-083) — for update_page_metadata, doesUserHaveAccess() plus checkLanguageAccess(), the same checks and the SAME neutral refusal string execute() uses. A preview can never show a page the run itself could not have written, and cannot be used to probe the page tree for existence.
  2. At reading. The inbox is reachable with the agent_approve grant (ADR-130), which is tool-level and decides nothing about individual records. So the card asks the tool a SECOND question — ToolPreviewInterface::mayViewerReadPreview() — about the backend user the page is being rendered for. update_page_metadata answers it with the same record check it used at production, applied to the viewer. Where the answer is no, the card says the preview is withheld instead of showing the lines.

ADR-133's gate is NOT part of this. It sits in ResumeCoordinator::approve(), on the DECISION, and never runs while the list is rendered. Reading the card and pressing "Approve" are gated separately, and only the second one passes through ADR-133.

What the read gate does and does not buy. It removes the disclosure this feature would otherwise create: an approver whose remit is operations rather than editing no longer reads the current metadata of pages they hold no rights on. It costs one bounded row read per pending call per render, and it can leave an approver with a partly blind card — they may still release a write whose "before" they were not shown, because the authority to approve is tool-level and this gate does not change that. That asymmetry is deliberate: withholding sight is cheap and reversible, withholding the decision is ADR-133's subject and a different change (issue #662, option 3).

Fail closed on every branch the card cannot resolve: no viewer, a tool that is no longer registered, or a tool under that name that offers no preview contract. The persisted preview outlives the registration that produced it, so "the tool cannot be asked" is a normal state, not a corrupt one.

Two bounds remain on what a permitted viewer sees: only the fields the call would write (never the whole row), each truncated to a 120-character excerpt.

The same limit stated the other way: a tool's preview must read only what the run's acting user may read, and must answer honestly about what its viewer may be shown. Both are contracts on implementors, written into ToolPreviewInterface, not something the loop can enforce for them.

The playground's two preview surfaces are not gated this way. Every one of its actions is admin-gated (denyNonAdmin()), and an admin passes every record check by definition, so a second question would have exactly one answer. The moment a non-admin reaches that module, it needs the gate the inbox has.

Consequences 

●● An approver sees what a write would replace, in the same card that asks them to release it. For the one shipped writer this is the difference between reading a model's proposal and reading a diff.

● The interface is opt-in and its absence is silent by design — a writing tool that skips it costs its approver the comparison and nothing else. That is the same trade as ToolEffectInterface, whose silence costs more.

SuspendedRunState grew a tenth constructor parameter. It is optional, last, and @api: the snapshot test records the new public property, and no existing caller changes.

ToolPreviewInterface carries two methods, not one: producing a preview and releasing it to a viewer are different authorisations, and only the tool knows which record its arguments name. The interface is not @api, so this costs nothing outside the extension.

✕ A preview runs a tool's read path at suspend time, on the loop's clock. It is one bounded read per previewing call in the turn, but it is work the loop did not do before, and a slow preview delays the pause the operator is waiting for.

✕ Rendering the inbox now costs one further record read per previewing pending call, for the read gate. The list is a handful of runs; a queue of thousands would need the answer cached per viewer.

✕ An approver may still RELEASE a write to a page they hold no rights on — the approval authority stays tool-level (ADR-133). They now do it without seeing the "before". Withholding sight without withholding the decision is the deliberate half-measure; issue #662 option 3 is the other half.

Revisit when 

A writing tool needs a preview of a RELATIVE change, or a second surface starts rendering previews outside the approval card. The first breaks the snapshot argument above; the second would be the moment to ask whether the lines should be structured data rather than text, which they deliberately are not today — one string list has one rendering, and three surfaces render it identically.

ADR-137: One candidate resolution for the primary's chain 

Status

Accepted

Date

2026-08-09

Context 

The walk over a primary configuration's fallback chain existed twice: \Provider\Middleware\FallbackMiddleware for pipelined calls and \Service\Streaming\StreamingDispatcher for streamed ones. Both applied the same four rules from ADR-021 — shallow, no self-retry, missing entry skipped, inactive entry skipped — from two separate pieces of code, so a fix to one was not a fix to the other.

The duplication is not free. \Service\Tool\TrustZoneResolver walks the same chain to derive the data-class ceiling for tools (ADR-094). That ceiling is only sound while the set a call may actually reach stays inside the set the ceiling considered. Two independent loops are two chances for that to stop being true.

Not every difference between the two was duplication, though. The health-aware reorder (ADR-063) applies to the pipelined path only, and the streaming path opens the primary itself while the middleware receives it already attempted.

Decision 

The candidate loop moves into one class, \Provider\Fallback\FallbackCandidateResolver , marked @internal. It owns the four ADR-021 rules and nothing else:

  1. It resolves, it does not order. The caller hands in the chain it wants walked. The health reorder stays in FallbackMiddleware , where its single caller is; streaming keeps the configured order. No RoutingPolicyInterface is introduced — ProviderHealthServiceInterface::reorder() has exactly one caller repo-wide, and an interface with one implementation and one reader is a declaration nobody reads.
  2. It does not own the primary. chainFor() removes the primary's own identifier from its chain; whether the primary is itself a candidate is the caller's business. The middleware's primary has already run in the pipeline; the dispatcher prepends it because it still has to open it.
  3. It does not log. A skipped entry is reported to the caller through a callback carrying the identifier and a FallbackSkipReason . The middleware words the two reasons separately, the dispatcher collapses them into one line; merging the rules must not rewrite either log surface.
  4. It resolves lazily. Each entry is looked up when the caller asks for it, so an entry behind the one that served is never queried.

TrustZoneResolver is not touched. Its optional repository argument is the fail-closed path: without a repository every chain entry resolves to null and the zone falls to EXTERNAL_GLOBAL, the most restrictive ceiling. Making it mandatory would trade that safety for symmetry and break six test construction sites.

The invariant that ties the two together — the set either path attempts is always a subset of the raw chain TrustZoneResolver::zoneFor() walks — is pinned as a test (Tests/Unit/Provider/Fallback/CandidateResolutionTest.php), not as a shared class. A shared class would have to own both the ceiling and the routing, coupling a security decision to a retry policy.

Consequences 

  • Both call sites take the resolver instead of LlmConfigurationRepository ; neither queries configurations itself anymore.
  • The two deliberate differences are now asserted per path, including a reflection assertion that the dispatcher has no health-service dependency — wiring one in is a routing change and fails that test.
  • Streaming no longer resolves the whole chain up front. When an early candidate serves, later entries are no longer looked up and a broken entry behind it no longer produces a skip warning. The pipelined path already behaved this way; the eager resolution was an artefact of building an array, not a decision. The trade-off is that a typo'd chain entry is now reported only once the primary fails, so it is pinned as a dispatcher-level test ( theStreamingPathLooksUpNoChainEntryWhileThePrimaryServes ) — going back to an eagerly built array fails it.
  • ADR-021's rules now have exactly one implementation. A change to them is a change to one class, and both paths inherit it.

ADR-138: Criteria-mode selection matches the operation, not only the criteria 

Status

Accepted

Date

2026-08-09

Authors

Netresearch DTT GmbH

Context 

A criteria-mode LlmConfiguration carries no model relation. Its model is chosen at call time from stored criteria — capabilities, adapter types, a context-length floor, a cost ceiling. Until now that was the whole input. ModelSelectionService::resolveModel() never learned which call it was resolving for.

So a configuration whose criteria say {"adapterTypes": ["ollama"]} could serve a tool call with a model whose own record states it cannot do tools. The selection succeeded, the adapter was built, and the request failed at the provider — as a transport-shaped error, several layers away from the configuration that caused it.

Two related facts shaped the fix.

The same call resolves twice. embedForConfiguration() resolves once outside the pipeline to build the embedding cache key and once inside the terminal to pick the adapter. Two resolutions that can disagree would let cache entries stored under model A serve a call that ran against model B. The eager streaming-capability check had the same shape.

The capability column never reached the entity. Extbase resolves property types through Symfony's PropertyInfo, whose ReflectionExtractor infers a collection from an adder/remover pair. Model::addCapability() / removeCapability() inflect to $capabilities, so the property resolved as array; the DataMapper has no array mapping and dropped the column. Every repository-loaded model came back with an EMPTY capability set. The pre-existing capabilities criterion therefore matched nothing in production either — the defect this ADR fixes was one of two, and the second one hid the first.

Decision 

Thread the operation into the resolution. ModelSelectionServiceInterface::resolveModel() and ConfigurationCallPlanner::resolveModel() take a ?ProviderOperation. It has no default: every resolution belonging to a concrete call must name that call, and the one caller that genuinely has none — the bare adapterFor() lookup behind getAdapterFromConfiguration() — says null out loud. In criteria mode the capability the operation requires is merged into the criteria under its own key before findMatchingModel() runs. Fixed mode is untouched: the operator named that model, so nothing is being chosen and there is nothing to constrain.

Both resolutions of one call pass the same operation. The embedding cache-key site and the embedding terminal both pass Embedding; the eager streaming check calls the planner directly with Stream rather than routing through the operation-less public entry point. A unit test asserts that the two embedding resolutions receive the same operation and return the same model.

Restore the capability mapping. An explicit @var string on Model::$capabilities puts PhpDocExtractor — which runs first — back in charge of the property type. Without it this ADR would ship decoration: every model would read as undeclared and the new check would never fire.

An empty capability CSV means undeclared, not "cannot". The field is optional and many installations never filled it. The operation-derived check is therefore skipped for such a model, in both switch positions. This is a separate criteria key from capabilities precisely so the two can differ: what an operator explicitly asked for is still matched strictly, and a model that declares nothing still fails that.

Enforcement is a fail-closed switch, following ADR-113. routing.operationCapabilityEnforcement defaults to enforce. Only a literal observe observes — a missing value, a malformed routing section, an unreadable extension configuration and a typo all enforce, so a broken setting cannot silently disable the axis. Fail-closed governs the SWITCH, not the empty CSV: reading an absent statement as a denial would break working installations for a fact nobody ever stated.

The map is narrower than the vocabulary. Only chat, vision and tools are enforced. chat is the only token every producer writes; the other two are written unevenly, and this decision rests on that being understood rather than glossed over.

Since #671 each discoverer seeds only what its provider's API substantiates. Mistral, OpenRouter and Ollama report tools and vision per model, so a record from them lacking one is a statement. Anthropic and the curated OpenAI / Gemini entries write both from curated knowledge. Groq reports nothing — its listing carries no capability field, so its models are seeded chat alone and a missing tools there is a gap, not a statement.

Enforcing vision / tools against a Groq-discovered corpus therefore refuses working models until an operator completes the checkboxes. That is the reason the enforcement switch exists, and the reason observe is the safer default for an upgraded installation.

No discoverer writes completion or embeddings at all, and streaming is written only by Gemini (derived from supportedGenerationMethods) and the curated OpenAI / Anthropic / Gemini entries — requiring those would refuse models that work, on the strength of a field their own discoverer never filled. A requirement no producer satisfies is not a check, it is an outage.

A misconfiguration is named, not disguised. When enforcement is on and the criteria match models but none that can serve the operation, resolution throws UnsupportedFeatureException naming the configuration, the capability and the operation. Criteria that match nothing at all still return null — that is the pre-existing "has no model assigned" condition and it keeps its behaviour.

UnsupportedFeatureException stays UNKNOWN in the failure classifier, and therefore not retryable. This is deliberate and must not be "fixed": the exception now reports the installation's own misconfiguration. A retry cannot repair it, and a fallback that silently answered from another configuration would hide the very defect this ADR exists to surface. The operator would see a working system quietly running on the wrong model.

Boundary: the generic path keeps its adapter checks 

chat(), complete() and streamChat() prefer the default DB configuration and reach resolveModel() through it — those calls get the check. Their ad-hoc branch does not, and neither do embed(), vision() or chatWithTools(), which always run ad-hoc: they synthesize a transient configuration carrying only an identifier — no model, no provider, no criteria — and resolve a provider by key instead. There is no model record to match against, so there is nothing for this ADR to check.

Those paths keep the adapter-level guards they already have (ToolCapableInterface, VisionCapableInterface, supportsFeature('embeddings')). This is stated as a boundary rather than papered over: making the generic path capability-aware means giving it a model record, which is a different change with a different blast radius.

Consequences 

Fixed-mode configurations — the majority — are unaffected.

Criteria-mode configurations become stricter for chat, vision and tool calls, and the capability criterion starts working at all now that the column reaches the entity. An installation whose model records understate what their models can do will see a resolution refused where it previously succeeded and failed later at the provider. The escape hatch is one setting, and the real fix is one checkbox on the model record.

Restoring the capability mapping is visible beyond selection: anything reading getCapabilities() off a repository-loaded model saw an empty string before and now sees the persisted value.

Revisit when 

The discoverers write the capabilities they currently omit. streaming is the nearest, but "closing the gap" now means something different than it did before #671: a discoverer may only write a token its provider's API substantiates, so Stream becomes enforceable when the remaining providers report streaming, not when someone fills the field in. Where an API stays silent — Groq's listing has no capability field at all — the gap closes through the operator's own checkboxes, which is a different mechanism and a slower one.

embeddings and completion need a producer before they mean anything at all.

Also revisit if the generic path ever gains a model record. The boundary above exists because it has none, not because operation matching is unwanted there.

ADR-139: Context assembly is a seam, not a provider registry 

Status

Accepted

Date

2026-08-09

Amended

2026-08-10 by ADR-143 and ADR-144

Authors

Netresearch DTT GmbH

Context 

A programme proposal asked for a ContextProviderInterface with supports() / provide(), a DI-tagged registry, a ContextRequest carrying actor, site, language, page and requested types, a ContextResult carrying items, classification, source, freshness and a token estimate, and two reference providers in the core.

The motivation is sound: consuming extensions want to feed editorial context — brand voice, audience, legal instructions — into a request without nr_llm knowing what any of that means.

The shape is not. Building it now would produce an interface with zero implementers and zero readers, which is the pattern ADR-120: The agent loop's tool gate is a required collaborator removed once already: an argument that looked like enforcement, was read by nothing, and bought false trust.

Four things about the code decided this.

There is no vocabulary problem to solve. Two mechanisms already carry editorial context. Skills (ADR-035: Skill ingest (GitHub-hosted SKILL.md sources)) carry instructions. Snippets (ADR-031: Tagged Prompt Snippet Library) carry fragments and are addressed by free-form tags that ADR-031: Tagged Prompt Snippet Library deliberately keeps as a convention between editors and consumers, not a core enum. A core-side vocabulary of context types — brand, audience, editorial — would move editorial semantics into a layer whose whole point is not to have them.

There is no entry point that could fill the request. A ContextRequest with site, language and page presumes callers that carry those. None does. The reference providers the proposal names (CurrentPageContextProvider, CurrentSiteContextProvider) would have to find the page themselves — which is a generic read-everything accessor, a standing non-goal.

``freshness``, ``validUntil`` and ``source`` have no reader. Nothing caches context, nothing invalidates it, nothing displays where it came from.

The budget problem is real, and it is not shaped like a registry. The two budgets that exist do not negotiate: the skill block is bounded in bytes by SkillComposer, the transcript is bounded in tokens by ContextWindowManager, and until recently the second did not know about the first. That was a defect and was fixed as one. It did not need a budget object with max provider count; it needed the two existing budgets to meet in the one calibrated estimator.

Decision 

Context assembly stays a seam between the parts that already exist. No ContextProviderInterface, no registry, no context-type vocabulary in the core.

Three consequences follow, and they are the whole decision.

One estimator arbitrates. Everything that reaches the wire is counted by TranscriptEstimator through ContextWindowManager::fit(), including payload that is not in the message list when the fit runs. That is why fit() takes tool specs and the injected skill block as parameters rather than growing a budget object: the arbitration point is the estimator, and it is already calibrated against real usage.

The tag vocabulary stays consumer-owned. ADR-031: Tagged Prompt Snippet Library is extended, not superseded. A configuration selecting snippets by tag is the supported way to attach editorial context, and the tags remain a convention documented for editors — new fragment kinds still need no nr_llm release.

Deferred, with named triggers. Two pieces of the proposal are not rejected, they are waiting for a reader — the same discipline ADR-122: The side-effecting tool contract waits for a side-effecting tool applied to the side-effecting tool contract.

What is deferred, and what would trigger it 

Context provider registry 

Revisit when a consuming extension has a context source that can be expressed neither as a skill nor as a tagged snippet. At that point the interface is designed against that source, not against a guess — and the trigger is a real consumer, not a hypothetical one.

A data classification for injected context 

Only tool output is classified today (ToolDataClassResolver), and the trust-zone ceiling is enforced in one place: the tool gate. Skills, snippets, the system prompt and task input carry no ToolDataClass and are checked against no zone. A configuration in the least-trusted zone can receive any of them.

This is deferred rather than ignored, and the reason is specific: all of that content already passes the input-guardrail screener on the way out (ADR-087: Input-side guardrails — screening and redacting the outgoing prompt), and secret redaction is a mandatory guardrail a configuration cannot select away. Screening runs on the assembled list, after the skill block has been injected, so the block is screened too. What is missing is the classification — a declared ceiling per source — not every check.

Revisit when a consumer injects content the screener does not cover, or when an operator needs to declare that a particular snippet or skill source must not leave a trust zone. Building it means an operator-declared column, a migration in the ADR-115: Tool data-class enforcement is the default for new installs shape (existing rows observe, new rows enforce) and a new governance decision case — a fail-closed axis over existing data, which is not something to add speculatively.

What this does not close 

Named here so the next reader does not mistake the seam for full coverage.

LlmServiceManager binds no context window at all. Its chat, completion and streaming paths inject skills and send; only ConversationService and ToolLoopService call ContextWindowManager. A long transcript sent through the generic API is bounded by the provider, not by us.

The string-prompt completion path injects the skill block into a prompt string rather than a message list, so it has no assembly order to reason about and no window binding either.

Both are known and neither is addressed here. Closing them means giving the generic paths a configuration to bind against, which is a different decision.

Consequences 

●● The proposal's largest piece is not built, and nothing is worse for it. The capability it aimed at — a consumer attaching editorial context — already exists through skills and tagged snippets.

● The budget question is answered where it lives. One estimator, one fit, and payload outside the message list is passed to it rather than accounted for separately.

◐ Consumers keep owning their vocabulary. A new fragment kind is an editorial act, not a release.

✕ Two gaps stayed open at the time of writing and were written down rather than closed: the generic paths had no window binding, and injected context had no trust-zone ceiling. Both were closed on 2026-08-10 by ADR-143 and ADR-144.

ADR-140: The effective-policy readout has no apply path 

Status

Accepted (the readout gains consumers — see ADR-145)

Date

2026-08-09

Amended

2026-08-10 by ADR-145

Context 

Governance is spread over ext_conf_template.txt, several TCA tables, be_groups and three dashboard widgets. There was no single place where an operator could see the effective state — the values the runtime actually applies right now. Four keys carry real decision content:

  • privacy.level
  • privacy.retentionDays
  • tools.dataClassEnforcement
  • skills.minTrustLevel

The obvious next step after showing a value is letting the operator change it there. That step is the decision this ADR records, and the answer is no.

Decision 

A read-only view, in an existing module, reading through the runtime resolvers.

  1. Read through the same resolver as the runtime, never a second parser. ToolCallPolicy::enforcing() moved verbatim into Service\\Tool\\DataClassEnforcementResolver , which both the gate and the view now ask. SkillComposerFactory exposes the previously private minTrustLevel() . Privacy keeps its existing PrivacyPolicyInterface . A view with its own copy of the parsing rules would drift from behaviour on the first change to either side — and a governance view that is almost right is worse than none, because an operator acts on it.
  2. The value shown is what the gate DOES, not the literal setting. tools.dataClassEnforcement is fail-closed (ADR-113): only a literal observe observes. A typo (observ) therefore reads as enforce in the view, because that is what the runtime applies. Echoing the raw string would tell an operator the axis is off while it is enforcing.

    The same rule forces a qualification on observe. The gate computes $this->enforcement->enforcing() || $tool instanceof RemoteToolInterface ( ToolCallPolicy::decide() ), so observe mode covers builtins only — an MCP tool above the ceiling is denied outright whatever the setting says (ADR-115). The row therefore carries a note saying so. The scenario is not exotic: DataClassEnforcementDefaultUpdateWizard pins any upgraded install that already has providers and never chose a mode to observe, and a server whose trust zone is unset falls back to EXTERNAL_GLOBAL — the lowest ceiling there is. An unqualified observe would send the operator whose MCP tool is being dropped looking anywhere but at the trust zone.

  3. A resolver that cannot be asked yields "unknown", never a value. No substituted default, no reconstruction from the raw setting. A row that admits it does not know is safe; a plausible wrong value is not.
  4. No apply path. See below — this is the actual decision.
  5. No provenance column ("shipped default" vs "explicitly set"). No resolver exposes provenance, and it is not reconstructable from the stored configuration either: TYPO3's own ExtensionConfiguration::synchronizeExtConfTemplateWithLocalConfigurationOfAllExtensions() (ExtensionConfiguration.php:197-218) merges every ext_conf_template.txt default into settings.php whenever an unknown key is read or the Install Tool is entered. By the time anything could ask, the shipped defaults are already stored values.
  6. No new backend module. ADR-119 already calls twelve flat entries a dumping ground. The readout is a Governance tab of the Overview module (nrllm_overview, action governance), not a thirteenth admin entry. Overview rather than Analytics: Analytics answers "what did this install spend and do over time" from usage rows; the readout answers "what does this install allow right now" — a static property of the install, which is what the Overview already reports through its readiness cards. Because LlmModuleController::buildDocHeaderTabMenu() builds links to real routes rather than in-page tabs, the tab needed a real action, a template, registration in Configuration/Backend/Modules.php and XLIFF keys in both language files.

Why there is no apply path 

The only API for writing extension configuration is ExtensionConfiguration::set() . Three properties, each verified against the shipped core:

  • It is ``@internal`` (ExtensionConfiguration.php:150) and documented as "Set a full extension configuration" — it takes the whole array and calls setLocalConfigurationValueByPath('EXTENSIONS/' . $extension, $value) (:166). There is no per-key write.
  • It materialises every shipped default. A caller has no source for the array other than get() , which returns the template-merged result. Our own DataClassEnforcementDefaultUpdateWizard::executeUpdate() (Classes/Updates/DataClassEnforcementDefaultUpdateWizard.php:77-85) does exactly this: get() → mutate one key → set(). Every default in ext_conf_template.txt becomes an explicitly stored value. storedEnforcement() (:121-131) — the "did the operator ever choose a mode?" probe ADR-115's wizard depends on — could then never return null again.
  • ``additional.php`` spoils it. The core docblock says so outright (ExtensionConfiguration.php:145-146): if that file overwrites a setting, ->set() "may not end up as expected". An apply button would report success and the next request would serve the old value — the worst failure mode a governance UI can have.

The Install Tool stays the place where instance-wide keys are set. It owns the write, the synchronisation and the cache flush; a second writer in a module would only add a way to get them out of step.

Constraints and honest limits 

  • The ADR-115 argument is weaker than it looks, and does not carry the decision alone. storedEnforcement() reads $GLOBALS['TYPO3_CONF_VARS']['EXTENSIONS'], whose docblock calls it the raw stored configuration "pre-template-merge". It is not: the core synchronisation writes the merged template into settings.php and into that same global. On an install that has entered the Install Tool since the default flipped, the "default vs chosen" distinction is already gone without any apply path. The apply path would guarantee the loss rather than cause it. The @internal full-array write and the additional.php hazard are the load-bearing reasons; ADR-115 is corroborating, not decisive.
  • The seven ``privacy.retention.*`` overrides are shown only where they deviate. On the shipped defaults they all read 0 and resolve to the global window, so listing them adds seven rows reading "30" and informs nobody — that argument covers a stock install and nothing else. Once an operator sets one, privacy.retentionDays is no longer the window that category is purged on, and a page promising what the install "actually applies" would carry a single retention number that is wrong for it. A category whose PrivacyPolicyInterface::retentionDaysFor() differs from retentionDays() therefore gets its own row, directly under the global one; the rest stay in the documentation (Data retention & purge).
  • "Unknown" is a guarantee, not a common state. All three current resolvers are fail-closed and swallow their own read errors, so on a working install every row is answered. The guarantee exists so a future resolver — or a partially booted container — can never be rendered as a value it did not return.
  • Read-only means read-only. The view has no state, no AJAX route, and no write of any kind; it is registered as an admin-only module action like every other nr_llm admin surface.

Consequences 

  • The enforcement read has exactly one implementation. The functional test wires the gate and the view from the same container resolver and asserts that flipping tools.dataClassEnforcement moves both — the view cannot drift.
  • ToolCallPolicy no longer depends on ExtensionConfiguration ; it takes DataClassEnforcementResolver as a required constructor argument. Behaviour is unchanged, including the "no configuration at all enforces" case the old nullable dependency produced.
  • SkillComposerFactory::minTrustLevel() is public. The composer is still built through create() ; the accessor only exposes what it already computed.
  • Operators still change these keys in the Install Tool. The readout links nowhere and changes nothing; it tells them what is in force.

ADR-141: Every executing segment holds a lease, or writes stop 

Status

Accepted

Date

2026-08-10

Amends

ADR-112 (where the fence arms, not how it works) and ADR-135 (its non-guarantee section)

Authors

Netresearch DTT GmbH

Context 

The ADR-112 write fence stamps a tool's declared effect on the run row before the tool runs and clears it after, both under an ownership-guarded UPDATE. A reaper reading a stamped NON_IDEMPOTENT_WRITE refuses to retry that run: the side effect may already have landed.

The stamp needs an owner. markPendingEffect matches on claimed_by = :owner, so a run nobody claimed cannot be fenced — and until this decision, the only segment that claimed a run was a queue worker.

The consequence was recorded honestly in ADR-135 and in WritePathAcceptanceTest: shipped writes ran unfenced. Two facts made it worse than "the queue path is fenced, the interactive path is not":

Nothing enqueued. AgentRuntimeInterface::enqueue() had no caller outside Tests/. The queue, the worker, the lease and the fence were a complete mechanism that no shipped entry point entered.

The write executes on the resume. ADR-134 binds a declared write to a human decision, and ToolLoopService throws ToolApprovalRequiredException before invoking the tool. The tool therefore runs in the continuation after Approve — and AgentRunRepository::conditionalClaim() explicitly wrote claimed_by = '', lease_expires = 0 on that transition. The one segment that executes side effects was the one segment that deliberately dropped its claim.

Write entry points, as inventoried against the code:

Entry point Reaches a tool through Held a lease
Tool Playground, batch run AgentRuntime::run() no
Tool Playground, streamed run AgentRuntime::run() no
Tool Playground, approve / submit input ResumeCoordinatorexecuteResume() no
Approvals inbox (AgentRunController) ResumeCoordinatorexecuteResume() no
Messenger handler runQueued() yes, but nothing enqueued
AI Tasks, wizard, specialized services no tool loop at all not a write path

Decision 

Every segment that can execute a tool claims the run it executes, and a side-effecting tool that cannot be fenced does not run.

Three parts, in that order of importance.

1. The guard. The fencing onBeforeTool hook is installed unconditionally. When the resolved effect is a write and the segment holds no persisted run or no lease, it throws WriteWithoutDurableExecutionException before the tool executes. Previously the hook was simply not installed in that case, so an unleased write proceeded silently. Fail-closed replaces fail-open, and this is what makes the property hold for entry points that do not exist yet: a new caller that forgets to claim its run cannot execute a write at all.

2. The claims. startRun writes a claim at birth for a synchronous run; claimForResume / claimForResumeFromInput write the winner's claim instead of clearing it. Identities come from ExecutionIdentity and name the segment kind first — resume:web-01:4711 — so a lease left behind says which entry point abandoned it.

3. The reaper rule. A leased segment is visible to the stale-run reaper, which is the point: an abandoned run settles instead of staying RUNNING forever. But a synchronous run and a resume store no request payload, and runQueued() refuses a QUEUED row it cannot rehydrate. Reclaiming one would strand it QUEUED forever, so the reaper dead-letters a stale run with no stored request rather than requeueing it. The fence check keeps priority: a run caught mid non-idempotent write is refused for that stronger reason first.

Why not route interactive writes through enqueue() 

The obvious alternative, and the one the programme plan proposed: have the playground and the resumes call enqueue() instead of run(). On the default SyncTransport the run would execute inline and arrive fenced.

It was rejected on three counts, in ascending order of severity:

  • enqueue() returns a uuid, not an AgentRunResult . Every interactive caller wants the result.
  • The queued message carries a uuid only, so the $onStep closure is lost. The Playground's streamed run (ADR-040/041) is built on it and would have to be rebuilt or dropped.
  • It does not reach the resume at all. A resume is not a queued execution: it continues a suspended state, not a stored request. Since the write executes on the resume, an enqueue-based fix would leave every actual side effect exactly as unfenced as before while appearing to solve the problem.

The fence hangs on the lease, not on the queue. Giving each segment a lease is therefore both narrower and more complete than moving segments onto the queue.

Consequences 

✓ Both shipped writers execute fenced, on the segment that runs them.

✓ A remote (MCP) tool that declares a write but whose operator did not set the approval flag — which ADR-134 exempts from the suspend rule, so it executes on the FIRST pass — is fenced there too.

✓ An abandoned interactive run or resume settles via the reaper instead of sitting RUNNING forever. This is new behaviour, not only a fence side effect.

✓ A future entry point cannot silently skip the fence. It either claims its run or its writes are refused.

◐ Leases now appear on rows that never carried one. claimed_by is diagnostic, but anything that treated a non-empty value as "this is a queue worker" is wrong now — read the segment prefix.

◐ One extra UPDATE per step boundary on interactive runs (the heartbeat). Steps are provider-call-slow; the cost is not measurable against them.

✕ Not exactly-once. ADR-112's limits are unchanged: a write that completed but whose fence-clear did not persist is indistinguishable from one interrupted mid-flight, and is refused a retry. Refusing a completed write's retry is the safe direction.

Revisit when 

A segment appears that legitimately cannot hold a lease — a read-only introspection path that still executes tools, say. The guard would refuse its writes, which is correct, but the question would then be whether such a segment should exist rather than whether the guard should soften.

Also revisit if enqueue() gains a shipped caller. The two mechanisms would then coexist, and the lease identity is what keeps their diagnostics apart.

ADR-142: One routing decision, with a reason per candidate 

Status

Accepted (the trace deferral is settled; the complexity-routing deferral is measured but still open — see ADR-156)

Date

2026-08-10

Amends

ADR-060 (quality is no longer only a separate hook)

Amended

2026-08-11 by ADR-156

Authors

Netresearch DTT GmbH

Context 

Criteria-mode selection worked and had exactly one production path — ModelSelectionService::resolveModel() , reached through ConfigurationCallPlanner . What it could not do is say why.

modelMatchesCriteria() returned a bool. A model that never appeared in a call was indistinguishable from one that lost on cost, and an operator asking "why is it not using the model I added" had nothing to read.

Two signals also sat outside the decision, for different reasons:

  • QualityAwareModelSelector (ADR-060) is a documented opt-in hook whose own docblock calls first-class wiring a deliberate follow-up. Nothing in the core calls it.
  • ProviderHealthService::reorder() (ADR-063) reorders the FALLBACK CHAIN. That is a different axis — which provider to try next after a failure — not which model to select.

So this is not a merge of competing routers. There is one path; it gains an explanation, and two measurements it never consulted.

Decision 

Discovery, then eligibility, then ranking — and the boundary between the last two is the load-bearing part.

Eligibility is a hard constraint with a reason. EligibilityEvaluator answers "may this model serve this call" and returns a RoutingRejectionReason instead of false. It is the ONLY implementation of that question: modelMatchesCriteria() now delegates to it rather than keeping a second copy of the predicates.

Ranking never revisits eligibility. A rejected candidate carries no score at all — not a low one — so there is no number for it to come back with. This is why RoutingCandidate is either eligible-with-a-score or rejected-with-a-reason and never both.

The reasons have an order, and it matters. The operator's own criteria are evaluated first and the operation capability (ADR-138) last. A caller reads OPERATION_CAPABILITY_MISSING as "would have served, but not this operation", and resolveModel() raises a misconfiguration error on it. Evaluated earlier, a model the criteria excluded anyway would report that reason and the error would name a model nobody wanted.

Measured signals are opt-in. RoutingPolicyMode::PROVIDER_PRIORITY is the default and reproduces the previous ordering exactly. balanced, quality and economy add quality and health. Opting in is deliberate: these signals change which model serves a call, and an installation that never asked must not get it from a version bump — the shape health.reorderFallback already uses.

Provider priority outranks every measurement. A priority is an operator's instruction; a score is evidence. Evidence does not overrule an instruction, so priority is a sort key ABOVE the score rather than a term inside it.

Absent data is not bad data. A model with no quality measurement contributes nothing to the weighted mean rather than a zero, and a provider with no samples in the window yields no health signal at all — ProviderHealthScore::NEUTRAL_SCORE already established that rule for the fallback chain. The consequence that makes the default safe: with no data, every candidate scores identically and the established tiebreaks decide.

Three named modes, not a weight panel 

An operator can state an intent. They cannot reasonably calibrate four coefficients against each other, and a backend full of sliders produces settings nobody can explain six months later.

What this does NOT do 

No request-complexity routing. The plan this work came from lists it, and it is deliberately not built: routing on an estimated complexity score needs evidence that the score predicts anything, and that evidence does not exist yet. Measuring it first, deciding later, is the same shape ADR-138 used for the operation-capability axis.

ADR-156 does the measuring, and still does not route: it names the three things that must hold before anything may.

No persisted decision trace, yet. A trace whose only reader is a future analytics view is a declaration nothing reads. The decision object exists and is returned; persisting it belongs with the surface that displays it.

That surface exists — ADR-148 explains a hypothetical decision, ADR-156 persists the real ones and reads them back on the same page — so the trace is now written.

No change to fixed mode. Nothing is chosen there.

Consequences 

✓ Every automatic selection can name the model it chose and the reason each other active model was not chosen.

✓ Hard constraints cannot be overridden by a score, structurally rather than by convention.

✓ The predicates exist once. modelMatchesCriteria() and the decision point cannot drift apart, because they are the same code.

✓ The default behaviour is unchanged, and the whole existing test suite passing untouched is the evidence.

QualityAwareModelSelector still exists and still has no core consumer. It is not deleted: it is documented public surface with its own semantics (a hard minQuality FILTER, which the ranking deliberately does not have — a minimum quality is a constraint, and constraints belong in eligibility). Whether it should become one is a separate decision, and one this ADR makes easier rather than answers.

◐ Collecting quality and health costs one lookup per candidate in the modes that use them. The default mode collects nothing.

✕ The weights are judgement, not calibration. They are chosen to be defensible and to keep the ordering stable, not because a measurement said 0.4 was right.

Revisit when 

Enough real decisions have been traced to say whether the modes correspond to anything operators actually want, or whether one of them is never chosen.

Also revisit when a minQuality floor is asked for: that is an eligibility constraint with a rejection reason, not a ranking weight, and it is the natural moment to fold QualityAwareModelSelector in.

ADR-143: Bound every send against the model that actually serves it 

Status

Accepted

Date

2026-08-10

Amends

ADR-107 (which model's window, and which paths bind one) and ADR-139 (its "generic paths bind no window" gap)

Authors

Netresearch DTT GmbH

Context 

ADR-107 keeps an agent transcript inside the model's context window. ADR-139 recorded that only two callers used it — ConversationService and ToolLoopService — so which API a consumer happened to call decided whether a long transcript was pruned or handed to the provider whole. That is issue #688.

Reading the code turned up a second, quieter defect in the paths that DID bind a window.

ContextWindowManager::fit() sized the budget from $configuration->getLlmModel(). A criteria-mode configuration carries no model relation — model_uid = 0 — and ConfigurationCallPlanner::resolveModel() deliberately does not write the resolution back, because mutating the entity would mark it dirty and Extbase would persist model_uid, silently converting a dynamic configuration into a fixed one. So on every dynamically-selected call the manager found no model, fell back to its unknown-window default, and sized the budget against a number that had nothing to do with the model on the wire.

Decision 

The window comes from the resolved model. fit() takes the model that will actually serve the send and prefers it over the configuration's relation; the response reserve follows the same model. Passing nothing keeps the entity's model, which is the fixed-mode case and the behaviour every existing caller had.

Every configuration-driven send of a transcript is bounded, at the point that knows the model. The bind sits inside the middleware-pipeline terminal in LlmServiceManager — chat, tool calling and, in its opener, streaming — because that is the first place the resolved model exists. For a stream it runs before the adapter is asked for the first chunk: once a stream is open there is nothing left to prune. A tool-calling send counts its tool schemas against the same budget, because they are on the wire with the transcript.

Embeddings are deliberately not bounded here. They carry neither skills nor snippets nor a transcript, and their size limit is the provider's own input limit rather than a context window to prune turns out of.

A completion reports; it does not prune. A raw prompt is a single unit: there are no older turns to drop, and silently shortening a caller's prompt would change what they asked for, which only the caller can judge. What the path does deliver is the decision being explicit — an overflowing completion is named, with the model and the budget it exceeded, instead of surfacing later as an opaque provider error.

Overflow at the floor still sends. The estimate errs high, so a payload that does not fit may well succeed; if it does not, the provider's own error is what the caller would have received anyway. Refusing here would turn a call that might have worked into one that certainly does not. This matches what ConversationService already did, and it is why the bound is a bound and not a gate.

Consequences 

✓ A criteria-mode configuration is sized against its real model. This is a behaviour change on the paths that already bound a window: a transcript that previously slipped through against a 128k assumption is now pruned against the 4k model actually answering.

✓ Chat and streaming through the generic manager are bounded like a conversation or an agent loop.

✓ Completion overflow is visible before the provider rejects it.

◐ Two fits can run for one send — a conversation or agent loop fits its transcript for its own semantics (dropped_turns is a stored fact, ADR-121), and the send-level bound runs again at dispatch. The second is a no-op when the first already fit, and it costs one estimator pass over a list that is about to cross a network.

LlmServiceManager gained two optional constructor arguments. Both default to null, so every existing construction keeps its exact previous behaviour — a null context window means "bounded by the provider", which is what these paths did.

✕ Not a token guarantee. The estimator is an estimate with a calibration factor; ADR-107's limits are unchanged.

Revisit when 

A consumer needs the send-level fit's decision as data rather than a log line — that is the same surface question the routing decision trace raised (ADR-142), and the two should get one answer, not two.

ADR-144: Injected context carries a declared data class 

Status

Accepted (the criteria-mode zone now comes from the resolved model — see ADR-149; the system prompt is classified after all — see ADR-155)

Date

2026-08-10

Amends

ADR-094 (the axis now binds in both directions) and ADR-139 (its "no trust-zone ceiling" gap)

Amended

2026-08-11 by ADR-149, and 2026-08-11 by ADR-155

Authors

Netresearch DTT GmbH

Context 

ADR-094 classifies what a tool RETURNS and refuses to offer a tool whose class exceeds the trust zone a run can reach. Nothing classified what a run SENDS. A configuration in the least-trusted zone could receive any snippet and any skill, and an operator had no way to say "this one must not leave this zone". That is issue #689.

The issue is careful about what this is not: all of that content passes the mandatory input-guardrail screener (ADR-087), and secret redaction is not selectable away. What was missing is the declaration — a per-source ceiling — not every check.

Decision 

The same scale, reused. Snippets and skills carry a ToolDataClass value, the enum tool output already uses. A parallel enum with identical cases would be the duplication the module seams exist to prevent, and TrustZone::permits() already answers the comparison.

One switch for one question. The gate reads tools.dataClassEnforcement — the ADR-113 switch — rather than adding a sibling. The question is the same: does a declared data class bind against a provider's trust zone. What differs is direction: ADR-094 asks it about what a tool may READ for a run, this asks it about what the run may SEND.

Undeclared is not a class. An empty value means no statement was made, and a source that made no statement places no constraint. This is what makes the axis safe to ship enforcing: an installation that has classified nothing behaves exactly as before. The migration risk was never in the switch — it is in guessing a value for data that already flows, and nothing here guesses.

Note the deliberate asymmetry with ADR-116: an undeclared MCP server is INERT, an undeclared snippet is unconstrained. A remote server is new capability an operator opts into and can be asked to classify first; snippets have shipped for months, and refusing them the moment a column appeared would break working installations for a value nobody was ever asked for.

Two sources, and only two. The snippets a configuration composes and the skills attached to it. The system prompt and the task input are deliberately not classified: neither has a per-record home for a declaration — a system prompt is a field on the configuration that already knows its own provider, and task input is whatever the caller passed this second. A column for them would be a declaration with nowhere to live and no one to set it.

Amended by ADR-155 for the system prompt: that argument holds for a fixed-mode configuration, whose provider is named on the record, and not for a criteria-mode one, which knows no provider until routing runs. Once ADR-149 made the zone follow the resolved model, a class on the system prompt gained a consumer — it constrains which models the configuration may resolve to. The task-input half stands unchanged.

The strictest declaration decides, because that is what the send carries. One confidential snippet makes the whole prompt confidential regardless of what accompanies it.

The refusal names the source, never the text. An operator told only "forbidden" has to go looking. The message and the audit row carry the snippet identifier or the skill name and the zone — and no content, because the content being sensitive is the entire premise.

Where the resolvers had to move 

Building this hit ModuleSeamTest::testCoreDoesNotDependOnTheToolModule: TrustZoneResolver and DataClassEnforcementResolver sat in the tool namespace, where the tool gate — their first consumer — had put them.

Neither is about tools. A trust zone is a property of a provider; the enforcement switch governs an axis. Core needs both to gate the send path, so the misfiling became load-bearing rather than cosmetic. They moved to Service\\Governance , and the exception EffectivePolicyReadout held in that rule — which existed only because of the same misfiling — disappeared with them. The rule now passes with a SHORTER exception list than before.

Consequences 

✓ An operator can declare "this snippet must not leave this trust zone", and the declaration is enforced on every configuration-driven send.

✓ The refusal is explainable: which source, which zone, which class.

✓ The governance audit gains a context_blocked decision, separate from tool_denied — collapsing them would make "which direction leaks" unanswerable.

✓ The module seam is cleaner than before, with one fewer exception.

◐ A criteria-mode configuration resolves to EXTERNAL_GLOBAL, because the zone comes from the model's provider and a criteria-mode record has no model relation. That is fail-closed and therefore the safe direction, but it means a criteria-mode configuration that only ever selects local models is still treated as external. Resolving the model first would make the gate depend on routing; ADR-142 has just built the decision point that would make that answerable, and it is the natural follow-up. Amended by ADR-149: the serving model is threaded in from the manager, so a criteria-mode configuration now takes the zone of the model routing selected. The fail-closed answer stays for the case where routing selects nothing.

◐ Two entities gained a column and a TCA field. Skill is @api, so its two accessors extend the public surface.

✕ This classifies SOURCES, not content. A snippet declared PUBLIC_CONTENT that in fact contains a credential is not caught here — that is the guardrail screener's job, and it runs regardless.

Revisit when 

A criteria-mode configuration needs its real zone rather than the fail-closed one. The routing decision from ADR-142 is what would supply it. Answered by ADR-149 for the configuration's own provider; the fallback hops are still read from their own relations.

Also revisit if a consumer injects context through a path neither snippets nor skills cover — that is the ADR-139 revisit trigger, unchanged.

ADR-145: Governance profiles describe a posture, they never apply it 

Status

Accepted (both open items are closed — see ADR-148 and ADR-157)

Date

2026-08-10

Amends

ADR-140 (the readout gains consumers, not an apply path)

Amended

2026-08-11 by ADR-148; 2026-08-11 by ADR-157

Authors

Netresearch DTT GmbH

Context 

ADR-140 gave operators a read-only view of the effective governance and argued the apply path down: writing extension configuration rewrites the whole merged array and would materialise every shipped default.

The next questions an operator asks are not "what is set" but "is this right" and "would this be allowed". Both are answerable without an apply path, and answering them is what this record decides.

Decision 

A profile is a definition. GovernanceProfile is an enum of named postures — local-only, controlled-cloud, enterprise-strict, development — and each one is a map of expected values. It enforces nothing, resolves nothing, and is never consulted at runtime. A profile that could enforce would be a second policy engine beside the one ADR-140's readout exists to make visible, and two engines that can disagree are worse than one nobody can read.

The comparison consumes the readout's output. GovernanceProfileEvaluator::deviations() takes the rows the readout produced as an ARGUMENT rather than fetching them. It therefore compares exactly what the operator is looking at, and has no way to read the resolvers a second time and disagree with the table above it.

Silence is a position. A profile makes no statement about keys it does not name, and the evaluator reports nothing for them. A profile with an opinion on every key would force operators to disagree with it about things it was never meant to describe.

A deviation carries where to fix it. There is no apply path — that is ADR-140's decision, unchanged — so a deviation that only said "wrong" would be half an answer. Each one names the place the value is set.

The simulator calls the real gate. ToolCallPolicy::decide() is the call the runtime makes; the simulator runs it and renders the answer. It is not a reimplementation of the policy — a simulator with its own copy of the rules is worse than none, because the two can disagree and only one of them runs.

The values are judgement 

The numbers in each profile describe a recognisable posture an operator can aim at. They are not derived from anything: nothing measured that 30 days is right for a controlled cloud. A deviation is therefore a question worth asking, never a defect, and the UI says so.

Consequences 

✓ An operator can answer "which governance applies", "how does it compare to the posture we intended", "where do I change it", and "would this specific call be allowed" — all from one page, all through the runtime's own resolvers.

✓ No second policy engine. The profile is data; the evaluator is a comparison; the simulator is a call to the existing gate.

✓ Drift is shown, never corrected. ADR-140's reasoning against automatic mutation is untouched.

◐ The simulator answers for the operator running it, using their own permissions. That is a real answer to a real question and it is honest about whose rights it used — but "would this be allowed for an editor" needs a user picker, which is a separate surface. ADR-157 built it, as a read-only resolution rather than an impersonation.

◐ The simulator covers the tool gate. The routing decision (ADR-142) was wired in the same way by ADR-148; the input-context gate (ADR-144) was wired in by ADR-157, which also folds all four axes into one verdict.

✕ A profile does not describe everything an operator might mean by a posture. It compares the four keys the readout reports, because those are the ones with a resolver to ask.

Revisit when 

The simulator needs an actor other than the operator, or a second gate. Both are additive: the shape — call the real resolver, render its answer — does not change.

ADR-146: Three more editorial writers, and what the third one reviewed 

Status

Accepted

Date

2026-08-10

Amends

ADR-135 (its "revisit at the third writer" trigger)

Authors

Netresearch DTT GmbH

Context 

Two writing tools shipped: update_page_metadata (ADR-135) and set_file_alternative_text. Both set named scalar fields on one existing record. ADR-141 then closed the gap that made them theoretical — the write fence now arms on every executing segment, not only under a queue worker's lease — so a third, fourth and fifth writer inherit a fenced execution path rather than each re-establishing one.

ADR-135 scheduled a review for exactly this moment:

"The next trigger is the third writer — and the question then is whether anything the trait deliberately left per tool has become common, not whether the trait should grow."

This record adds the three writers and answers that question with what five implementations actually show.

Decision 

Three more purpose-built editorial tools, on the terms the first two established: disabled by default, in the editing group, with an explicit ToolEffect, a human approval before every call (ADR-134), a preview at suspend (ADR-136), a write through the DataHandler under the acting user's permissions (ADR-083), a read-after-write verification, and a refusal vocabulary that never confirms a uid exists.

move_content_element

Moves one content element to a page and a column. Nothing is created and nothing is destroyed: the record keeps its uid, its content, its language, its history and its references. That is what makes it the safest of the three, and why it is the only one of them that is idempotent.

Both ends are authorised with Permission::CONTENT_EDIT. Moving an element out of a page edits that page's content as much as moving one in, so one grant is not enough. An after_content_uid anchor must be on the target page and in the same language; a wrong anchor is refused rather than silently corrected, because placing the element "somewhere" would be a correction of an instruction the model got wrong.

The destination column is always sent explicitly, through the DataHandler's extended paste form (['action' => 'paste', 'target' => …, 'update' => ['colPos' => …]]), so the element lands where the approval card said it would even when the anchor sits in a column the caller did not expect.

create_content_element_draft

Creates one content element. The first writer that brings a record into being, and every part of it is arranged to keep that commitment small:

  • Always hidden. There is no argument to switch it off. Publishing is a separate act with a separate audience, and the approval that let the tool run approved a draft.
  • The content type is an allow-list intersected with the live TCA: header, text, textmedia, bullets, and only those an installation declares. Types whose payload is configuration rather than prose — list, html, shortcut — are unreachable.
  • The field set is fixed: headline, body, column, language, position.

bodytext reaches the DataHandler and its RTE transformation exactly as an editor's input does. It is bounded in length and not otherwise filtered: an editor may write the same markup by hand, and a tool enforcing a rule the CMS does not have would be enforcing a rule nobody agreed to.

create_translation_draft

Localizes one page or content element by running core's own localize command rather than copying fields between records. Connected-mode translations, the translation parent, inline children and every localisation hook are core's business; a second implementation would drift.

Two things are added on top, and both are the reason the tool exists:

  • The result is hidden. localize copies the source's visibility, so a translation of a live page would go live the moment it was created.
  • An existing translation stops the call, named in the refusal. overwrite is the only way past it: it deletes that translation through the DataHandler — recoverably (deleted = 1) and in sys_log — and the approval card carries that on a line of its own, so an approver who skims cannot miss it.

What is not re-implemented: whether the target language exists for the record's site, and whether the source is a well-formed default-language record. Core's DataHandler::localize() checks both. Its permission bar is not reused — it asks only for Permission::PAGE_SHOW, which is far too weak for a write, so the tool checks PAGE_EDIT (for a page) or CONTENT_EDIT (for an element) itself.

The effects diverge, exactly as ADR-135 predicted 

ADR-135 kept getEffect() per tool against the argument that "a third writer may be admin-only or non-idempotent, and a trait answering for it would turn a decision into an inheritance". That was a prediction. It is now an observation:

Tool Effect
update_page_metadata IDEMPOTENT_WRITE
set_file_alternative_text IDEMPOTENT_WRITE
move_content_element IDEMPOTENT_WRITE
create_content_element_draft NON_IDEMPOTENT_WRITE
create_translation_draft NON_IDEMPOTENT_WRITE

The two creations are not repeatable for different reasons, and both matter to an at-least-once runtime (ADR-104):

  • create_content_element_draft has no caller-supplied key, so a repeat leaves two drafts where one was asked for.
  • create_translation_draft is worse than that. Without overwrite a repeat refuses, so a reaped run that already succeeded would report failure for a write that happened; with overwrite it discards a translation a human may have started editing between the two attempts. Both are wrong answers, and a runtime must not produce either on its own.

Had the trait answered getEffect() for all writers, the fourth one would have inherited IDEMPOTENT_WRITE silently and the reaper would have been free to double it.

The review ADR-135 scheduled 

What has become common: one thing, and it is a query rather than a decision. Four of the five writers read a row by uid with only the deleted restriction. The restriction choice is the same in all four for the same stated reason: a hidden or timed-out record is still a record an editor may work on.

That answer needed splitting once it was measured. The duplication detector put the three new tools at 5.1 % new duplicated lines against a 3 % gate, and named 191 lines across them — not only the lookup but the unknown-argument refusal and the viewer gate, all three written three times in one pull request. Three copies made in one sitting are copy-paste, not three decisions, so they are extracted into PlansOneEditorialWriteTrait, used by exactly the three tools that share the shape. The two shipped writers keep their own lookups and are not touched.

What has not: everything else the trait left per tool, and each for the reason it was left:

  • The neutral refusal string. Four distinct strings now, because each pairs with the shape of what its tool addresses — a page, an asset, a content element, a record of either table. A shared string would break that pairing four ways instead of two.
  • The authorisation. Four different decisions: Permission::PAGE_EDIT on the record itself, CONTENT_EDIT on its page, CONTENT_EDIT on two pages, and a permission that depends on which table is being translated.
  • The read-back. Five different shapes: a field map against a re-read row, one column of one record, a position, a created row's identity, and a translation's language-plus-parent-plus-hidden triple.
  • The four declarations. See above — they diverged.

The decision: WritesThroughDataHandlerTrait does not grow. The question ADR-135 posed was what has become common across the writers, and the honest answer is a row lookup, not a mechanism. That trait carries what all five share; putting a query into it means either binding it to pages or adding a generic getter, and it means editing two shipped, tested writers to route through it. Neither buys what the trait bought the first time, which was that a decision stopped being made twice.

A second trait for the three tools that genuinely do share a shape is a different question and costs neither. The two traits are separated by what they answer: mechanics every writer performs, versus the consequences of resolving and authorising once for both the write and its preview.

One resolution for the write and the preview 

The three new tools share a shape the first two do not: a private plan() that resolves and authorises everything once, returning either the plan or the refusal message, and is called by both execute() and previewCall(). PlansOneEditorialWriteTrait declares it abstract and builds on it: the viewer gate is plan() asked about the viewer instead of the acting user, which is why one trait can carry it for all three.

It exists because these three have more to get right than a field map. The approver must read the destination the write will actually use — a computed column, an anchor, a translation that is about to be discarded — and two implementations of "where does this land" would eventually disagree with each other in front of somebody about to press Approve.

update_page_metadata and set_file_alternative_text are not retrofitted to it. They are shipped, tested code, the review ADR-135 asked for was about the trait rather than about the writers, and rewriting a working implementation to match a newer one is a change nobody asked for. A sixth writer should use plan().

Consequences 

✓ Five editorial writes are available where two were, each still one named act on one record.

✓ The write fence, the approval pause, the preview and the fail-closed audit apply to all five without any of them arranging it — that is what ADR-141 bought.

NON_IDEMPOTENT_WRITE has real consumers for the first time. The retry decision that reads the persisted effect was previously exercised only by tests.

create_translation_draft's overwrite is genuinely destructive. It is behind the approval pause, it names the translation it will discard, and the deletion is recoverable and logged — but a human who approves without reading loses work.

✕ The row lookup still exists twice — once in each of the two shipped writers, which this record deliberately does not touch. Only the three new tools share one.

create_content_element_draft can create a free-mode element in a non-default language, which is legitimate but is not a translation. The tool description says so on the wire and points at create_translation_draft; a model that ignores both produces an element an editor has to clean up.

Revisit when 

A sixth writer is proposed, or any writer needs to write more than one record per call. The one-record rule is what makes every refusal whole and every preview readable; a tool that batches would need a different answer to "what did the approver agree to", not a bigger version of this one.

Also revisit if NON_IDEMPOTENT_WRITE ever produces a reaped run in practice. The effect is declared and the runtime honours it, but no production installation has yet had one abandoned mid-creation, so the failure path is argued rather than observed.

ADR-147: No Symfony AI bridge while it is below 1.0 

Status

Accepted

Date

2026-08-10

Authors

Netresearch DTT GmbH

Context 

An adapter that delegates to Symfony's AI components was proposed as the last, explicitly optional item of the post-0.27 programme. This record decides against building it now, and — more usefully — writes down what was measured, so the decision can be re-made against facts rather than re-argued from memory.

The reasoning that first recommended against it was wrong on its main premise. It assumed that seven first-class adapters plus OpenRouter plus "any OpenAI-compatible endpoint" left no meaningful coverage gap. That is not what the code shows.

The gap is real 

symfony/ai-platform ships 37 bridges (src/platform/src/Bridge at v0.12.0). nr-llm ships 7 provider adapters — OpenAI, Anthropic Claude, Google Gemini, Groq, Mistral, Ollama, OpenRouter — plus Azure OpenAI and any OpenAI-compatible endpoint.

Most of the difference is reachable today through the OpenAI-compatible route or through OpenRouter. Two are not, and they are the two that matter commercially:

  • AWS Bedrock — SigV4-signed, its own request shape. Not OpenAI-compatible.
  • Google Vertex AI — Google-Cloud authentication and its own endpoint layout.

Both are what regulated and public-sector installations ask for by name, and neither can be reached by configuring a custom endpoint. Cohere, DeepSeek, Perplexity, HuggingFace, Replicate, Voyage and the speech bridges are further gaps of smaller weight.

The technical fit is plausible 

This is not a case of an abstraction that cannot carry what the extension needs. symfony/ai-platform has TokenUsage (with a streaming listener and an aggregation), StreamResult, ToolCall and ToolCallResult, and a per-bridge result contract. The pieces the middleware pipeline needs — usage for UsageMiddleware, a stream the redaction window can wrap, tool calls in a shape ToolLoopService can read — all exist.

Decision 

Do not add a Symfony AI adapter while ``symfony/ai-platform`` is below 1.0 — not as a hard dependency, and not as an optional one.

The blocker is the version, and only the version. symfony/ai does not exist as an installable package; the real packages are symfony/ai-platform and symfony/ai-agent, both at v0.12.0. Below 1.0 there is no backward compatibility promise, and every 0.x minor may change the contract an adapter is written against.

That matters more here than it would in an application:

  • nr-llm has to keep working on TYPO3 13.4 LTS for years. An installation that took the adapter would be pinned to whatever 0.x resolved at install time, and a security update to an unrelated part of the extension could drag a breaking platform minor in with it.
  • ADR-090 keeps nr-llm a single extension until 1.0. There is no separate package a volatile dependency could be quarantined in, so a 0.x requirement would sit in the same composer.json as the LTS promise.
  • Every nr-llm adapter declares its capabilities per model and is covered by unit and integration tests. One adapter fronting 37 bridges cannot make that declaration honestly: the capability set would differ per bridge, and no test in this repository can exercise bridges it has no credentials for.

Not even as an optional dependency. suggest plus a guard would remove the risk for installations that do not opt in, and that shape is the right one to build eventually. It is still wrong to build now: it would ship a capability surface that changes under the installation whenever the upstream 0.x moves, documented as if it were supported. A declaration the repository cannot stand behind is worse than none.

Consequences 

✓ No pre-1.0 dependency enters an extension that carries an LTS promise.

✓ The decision is now attached to a measurement rather than to an assumption. The next person to ask "why is there no Symfony AI provider?" gets the real answer — the version — rather than the wrong one that the coverage is already complete.

AWS Bedrock and Google Vertex AI remain unreachable. This is a real product limitation, not a technicality, and it is the price of this decision. An installation that needs either today has to run a proxy that exposes an OpenAI-compatible endpoint in front of them.

✕ The gap widens while Symfony AI adds bridges and nr-llm does not.

Revisit when 

Either of these, whichever comes first:

  1. ``symfony/ai-platform`` reaches 1.0. At that point build it in the optional shape: suggest plus require-dev, one adapter that reports itself unavailable when the package is absent, and a capability declaration derived per configured bridge rather than claimed for all of them.
  2. A named requirement for Bedrock or Vertex AI arrives. Then the decision is a trade rather than a default, and the honest options are the 0.x dependency, a dedicated first-class adapter for that one platform, or a proxy in front of it. A dedicated adapter is the cheaper answer for a single platform — the bridge only pays off across many.

Do not revisit merely because the bridge count grew. The count was never the argument.

ADR-148: The routing readout is a second gate on the Governance tab 

Status

Accepted

Date

2026-08-11

Amends

ADR-145 (the simulator gains the second gate it named)

Authors

Netresearch DTT GmbH

Context 

ADR-142 made an automatic model selection explainable: RoutingDecisionService::decide() returns a RoutingDecision that carries the selected model, every ranked candidate with its score and signals, and every refused candidate with its reason.

Nothing read it. The decision was produced on every criteria-mode call and thrown away except for ->selected, so the question an operator actually asks — "why this model and not that one" — had no answer anywhere in the backend.

ADR-145 built the shape for answering such a question: a read-only surface that calls the runtime's own gate and renders what it says. It closed by naming the routing decision as a second gate that was "answerable the same way and not wired in yet". This record wires it in.

Decision 

A section on the Governance tab, not a tab of its own. ADR-145 already established that page as the place an operator asks "which rule applies here, and what would it say about this call". A second tab would need its own module route, its own doc-header entry and its own template, and would split one question — "why does this configuration behave like this" — across two pages. The section is admin-only for the same reason the rest of the page is: the module registration in Configuration/Backend/Modules.php is access: admin.

It calls the real decision point. ModelSelectionServiceInterface::explainRouting() runs the same RoutingDecisionService::decide() the runtime runs. There is no second ranking, no second eligibility check and no second reading of the enforcement switch. A readout with its own copy of the rules would be worse than none, because the two can disagree and only one of them runs.

The readout lives on the selection service. It could have been a separate readout service in the shape of EffectivePolicyReadout . It is not, because ModelSelectionService already owns all four predicates the answer needs — the fixed-vs-criteria branch, the stored criteria, the OperationCapabilityMap lookup and the routing.operationCapabilityEnforcement switch — and a separate service would have had to own second copies of every one. The rule that the operation capability joins the criteria only while enforcement is on now exists once, in constrainedCriteria() , read by both the resolution and the readout.

Fixed mode is reported as no decision. A configuration that names its model chose nothing: there are no candidates, no ranking, no policy mode and no rejection reasons. RoutingReadout therefore has two states, and every field that describes a decision is null in the fixed one. Rendering a fixed configuration as a decision with a single winning candidate would invent reasoning the runtime never performed, and an operator would then debug criteria that are not consulted.

Trying a policy mode changes nothing. decide() gained an optional ?RoutingPolicyMode argument. It is evaluated for that one call; the install setting is neither read nor written, and the next call without it is back to the configured mode. The alternative — writing the setting and reading it back — is the apply path ADR-140 argued down, for the same reason: writing extension configuration rewrites the whole merged array.

The narrowest widening that makes it reachable 

Three changes, and no more:

  • RoutingDecisionService::decide() takes an optional policy mode. Existing calls are unchanged.
  • ModelSelectionServiceInterface gains explainRouting() , because a controller cannot reach a concrete @internal service's method through the interface it is wired against. decide() deliberately stays OFF the interface: it takes a raw criteria array and knows nothing about the fixed-vs-criteria branch, so a controller calling it would be choosing which half of the rule to apply.
  • Nothing changed in DI. RoutingDecisionService stays private and @internal; private services are injectable, and only a direct container fetch would have needed public: true.

Both enums gained label keys 

RoutingRejectionReason and RoutingPolicyMode had no labels, because nothing rendered them. They follow GovernanceProfile::getLabelKey() — including its get… prefix, which exists because Fluid reaches a method only through the get/is/has convention and a plain labelKey() yields an empty translation key that throws at render time.

RoutingDecision::noCandidates() is deleted 

It was dead: decide() never called it. Giving it a caller would have been decorative — on the empty-catalogue path decide() already constructs the byte-identical value, so the named constructor was a synonym rather than a distinction. The distinction is real, though, and it now has a reader at the other end: RoutingReadout::isEmptyCatalogue() separates "no active model was even considered" from "every candidate was refused", because the two need opposite fixes and the page says which one happened.

Consequences 

✓ An operator can answer "why this model", "why not that one", "what would economy mode pick" and "is the operation-capability switch actually enforcing" — from the runtime's own decision, on the page that already answers the governance questions.

✓ A fixed-mode configuration is answered honestly: nothing was decided, and the page says so instead of manufacturing a one-candidate decision.

✓ The signals table distinguishes "no data" from a measured zero, which is the distinction RoutingCandidate carries and the one a Fluid conditional would have destroyed. The flattening happens in the controller, as the dashboard already does for its bar widths.

◐ The readout answers for the configuration and operation the operator picks, under their own backend session. It does not simulate another user — routing has no per-user axis today, so unlike the tool gate there is nothing a user picker would change.

◐ Only the operations that OperationCapabilityMap maps to a capability are offered. The rest constrain nothing, and offering them would promise a dimension the decision does not have. "No operation selected" is reported as its own state rather than as an operation that requires nothing: RoutingReadout carries whether one was named alongside the capability it required, because a null capability has both causes and one sentence for the two would describe an operation the operator never chose.

✕ No apply path, and no way to persist a tried policy mode from this page. ADR-140's reasoning is untouched.

Revisit when 

Routing gains a per-actor or per-site axis, or the decision becomes worth persisting per call rather than recomputing on demand.

ADR-149: A criteria-mode trust zone comes from the resolved model 

Status

Accepted

Date

2026-08-11

Amends

ADR-144 (where a criteria-mode zone comes from)

Authors

Netresearch DTT GmbH

Context 

ADR-144 shipped with a known hole and named it as its own revisit trigger. TrustZoneResolver::zoneFor() read LlmConfiguration::getProvider() , which reads through the configuration's model relation. A criteria-mode configuration has none — model_uid = 0, the model is chosen at call time — so every such record answered EXTERNAL_GLOBAL, however local the model routing actually picked. A configuration that only ever selects an on-premise Ollama could not be given a confidential snippet. That is issue #723.

This is the same defect ADR-143 fixed one axis over: the context window was also sized from the configuration's relation and also found nothing there. The answer is the same shape — take the model that will actually serve the call.

Decision 

The zone comes from the serving model when one is known. zoneFor() takes an optional resolved Model and reads the primary zone from ITS provider. Passing nothing keeps the configuration's own relation, which is the fixed-mode case and what every existing caller had.

Fixed mode is untouched, structurally. In fixed mode the configuration's provider is the model's provider — getProvider() is a delegation to the relation — so a resolution there could only return what the gate already had. LlmServiceManager therefore resolves for the gate ONLY in criteria mode. The invariant is a branch, not a coincidence.

A routing failure stays a routing failure. Criteria that match no model, or match only models that cannot serve this operation, throw during resolution. That exception belongs to the dispatch that follows — which resolves again and raises it with its own semantics — so the resolution done for the gate swallows it and hands the gate no model. With no serving model there is no serving provider, and EXTERNAL_GLOBAL remains the honest answer. It is also the answer this path already gave, so nothing is newly refused.

The gate does not drive routing. Resolving inside InputContextTrustGate , or reaching RoutingDecisionService from it, would invert the dependency: a governance check would decide which model serves a call. The model is threaded in from LlmServiceManager , which already knows the operation and owns the dispatch. The gate stays a consumer of a decision it cannot influence.

One operation, one selection. The manager resolves with the SAME ProviderOperation the terminal will use, which is the ADR-138 rule for two resolutions of one call. A gate judging a model the send never runs on would be worse than the fail-closed answer it replaced.

What is deliberately not widened 

The fallback hops. zoneFor() still reads each fallback configuration's own relation, so a criteria-mode fallback still contributes EXTERNAL_GLOBAL. Resolving a model per hop would run the routing decision once for every entry of a chain that may never be walked, to answer a question about a provider the call may never reach. Fail-closed is the right default for a hop that has not happened.

:php:`ceilingFor()`, and with it the ADR-094 tool gate. ToolCallPolicy still asks zoneFor() with the configuration alone, so a criteria-mode run is still offered only the tools an external zone permits. That leaves the two directions of one axis briefly asymmetric, which is stated here rather than hidden: the READ side needs the model at a different point in the run — tools are selected before the loop, and the loop's own resolution is not this seam — and widening a signature for a caller that would have to be rearranged first is the declaration-nothing-reads shape. The asymmetry is fail-closed in the direction that matters: the tool gate offers LESS than it could, never more.

Consequences 

✓ A criteria-mode configuration that routes to a local model can carry a classified snippet. The ADR-144 hole is closed for the primary provider.

✓ The refusal is checkable: the context_blocked audit row now names the provider and model the zone was read from, instead of the empty relation a criteria-mode record has.

◐ A criteria-mode send resolves its model twice on the pipeline path — once for the gate, once in the dispatch — and three times on the streaming path, where LlmServiceManager::streamChatWithConfiguration() resolves once more for its eager capability check before the opener resolves again. In the default routing mode each of those is one extra findActive() query and an in-memory evaluation per send, against a call that is about to cross a network to an LLM. Memoising the decision per call would remove the extras, but that changes the routing invariant (ADR-142) rather than this gate, and it is not built here. Fixed mode adds nothing.

The cost is paid before the gate's own short-circuit, so an installation that has classified nothing — the majority, since the column ships undeclared — pays it for an answer the gate then discards. Resolving lazily would mean handing the gate a callable, which is the same inversion in slower motion: the gate would still be the thing that decides when routing runs. The cheaper answer is one resolution per call for everyone, and that lives in the planner, not here.

◐ Enforcement can now REFUSE where it previously refused for a different reason, and permit where it previously did not. An installation that had classified sources and criteria-mode configurations was refusing all of them; after this it refuses only the ones whose selected model is genuinely external. No configuration whose zone was read from a provider it actually reaches becomes refused.

One shape does newly refuse: a criteria-mode record that still carries a model_uid from an earlier fixed-mode edit. The TCA displayCond on that column hides the field when the mode is criteria; it does not clear the value, and nothing else clears it either. Such a record used to be judged against that stale relation — a call it never reaches — and is now judged against the model the criteria select. Where the stale relation is local and the criteria select an external model, a send that used to be permitted now throws.

✕ Not a guarantee about the model that answers. The permit is computed from the gate's resolution; the send runs on the dispatch's own. The window between the two is the one ADR-138 already lives with, but what falls through it is no longer a mismatched cache key or a capability check against the wrong adapter — it is a classified snippet reaching a provider the gate never approved. It does not take a change in the model set, either: CandidateRanker feeds measured quality, health and cost signals into the ordering for every mode that uses them, so the winner can move between two resolutions of one call. Before this record criteria mode was always EXTERNAL_GLOBAL, so the disagreement had no governance consequence at all.

Revisit when 

The tool gate needs the same treatment. It is the other half of the axis and the asymmetry above is a debt, not a design; the work is finding the point in the tool loop where the serving model already exists.

The fallback chain needs its real zones too — that is the same question this record answers for the primary, and it needs a cheaper resolution than one routing decision per hop before it is worth answering.

A per-call memo on the routing decision is now the answer to the ✕ above and not only to its cost: one resolution per call closes the window in which the gate's model and the dispatch's can disagree. It changes the routing invariant (ADR-142) rather than this gate, which is why it is not built here, but it is the change this record most wants.

A criteria-mode record's leftover model_uid is load-bearing in two directions now — it is what the pre-ADR-149 zone was read from, and it is what a switch back to fixed mode restores. Clearing it on the mode switch, in a DataHandler hook or an update wizard, would remove the newly-refusing shape above; it is not done here because it changes what a mode switch does to stored data, which is a decision about the record, not about the gate.

ADR-150: A submitter may only feed a tool they could run, on the turn they saw 

Status

Accepted

Date

2026-08-11

Context 

ResumeCoordinator::submitInput() authorised the submitter with the BackendUserGrant::AGENT_APPROVE grant and nothing else. It never checked them against the tool whose input they were supplying, while ToolLoopService::resumeWithInput() executes the pending calls under the RUN OWNER's identity (ADR-083). A non-admin holding the grant could therefore satisfy an admin-only tool that then ran on the owner's authority — the same confused deputy the approval path closed in ADR-133 (#622).

It also had no turn binding. The approval path pins a decision to the turn the operator reviewed (ADR-132); the input path had no equivalent, so a submission could not be shown to belong to the form it was written for. Worse, the state that EXECUTED was the pre-claim copy: a lost race let another submitter resume the run and suspend it again on a different turn, and the values were then fed into that one.

Both gaps were latent, not shipped: no builtin implements RequiresInputInterface , and a functional tripwire ( InputPauseCoverageTest , #680) pins the empty list. #690 is the open half of #649, and this record answers the two questions that tripwire poses.

Why ADR-133's gate could not simply be copied 

Because its selection rule would select nothing here. ADR-133 checks the pending calls that DECLARE a write, because an unattended write is what must not happen. An input-requiring tool declares no effect at all: the input and approval markers are mutually exclusive at registration (ADR-105) and it is the write EFFECT that implies approval (ADR-134), never input. A write filter on this path is a gate that never fires.

The danger on this path is also not the same one. It is not that something changes state unattended — it is that a user who may not run a tool supplies the ARGUMENTS it runs with, under someone else's identity, and those values flow back into the model context as untrusted content.

Decision 

The submitter passes the same gate the execution would, on EVERY pending call. ResumeCoordinator::submitInput() resolves the SUBMITTER's live backend user and asks ToolCallPolicy::decide() about every call of the pending turn — not only the writing ones — plus the state's declared inputToolName . The declared tool is appended only when the pending calls do not already name it — normally the turn's own call covers it, so no tool is asked about twice; the append exists so that a degenerate state whose pending calls do not name it cannot become an ungated submit. A denial refuses the submission with SubmitterNotPermittedException .

One gate implementation, two selection rules. The walk that asks the policy is shared with approverRefusal() ; only the predicate that picks the calls differs (writesOnly true for the approval path, false here), and the two paths raise their own exception so the message and the surface's wording match what the user was doing. There is no second notion of "may run this tool".

Execution identity is unchanged. ADR-083 stands: the turn still runs as the run owner. This is a second, independent condition on the SUBMISSION.

A service account may not supply input at all, and neither may a human whose uid no longer resolves to an enabled backend user — the same fail-closed reasoning as ADR-133, and here it is not even partial: there is no read-only half of the input path to keep working, because an input pause always exists to run one specific tool.

A submission must name the turn its form was rendered from. InputSubmission gained an optional-in-signature, mandatory-at-runtime $turnDigest . Null and mismatching are the same fact — the turn is not known — and both are refused with StaleInputTurnException .

The input digest lives in :php:`PendingTurnDigest`, not beside it. ADR-132's "one definition" is the point of that class, so the input binding is a second method on it, forInputState() , rather than a parallel implementation. It covers the pending calls PLUS the target tool and the declared input schema. forState() is byte-identical to before, so every approval card already rendered stays valid.

The two extra fields are what an input pause is decided on. The tool name is what resumeWithInput() dispatches the values onto. The schema is what the operator's form — and the pre-claim validation — were built from, so a matching digest also proves the submitted values were validated against the schema the run is still suspended on.

Neither digest covers the run uuid, although #690 lists it. It is not a field of the turn, and omitting it changes nothing that can be exploited: a digest is only ever compared against the state of the run named in the same call, so a digest borrowed from another run can only match when that run's turn is byte-identical — same tool, same arguments, same schema — which is not an escalation. Folding the uuid in would make the digest differ in kind from the approval one for no gain.

Both gates judge the state loaded AFTER the claim, and that state is what executes. Before this record submitInput() executed the pre-claim copy; it now re-reads and re-decodes the freshly claimed row exactly as approve() does, and a state that cannot be decoded there settles the run rather than leaving it RUNNING.

Schema validation stays pre-claim. That is ADR-105's deliberate divergence and it is what makes a bad submission resubmittable with nothing consumed. Gate 1 is what carries its verdict forward onto the post-claim state, because the input digest covers the schema.

A refusal releases the run. release() now restores the pause the state describes — WAITING_FOR_INPUT when it names a target tool, WAITING_FOR_APPROVAL otherwise — read off the state rather than passed in, so no call site can restore the wrong form. Nothing executed, nothing settled, and somebody who does hold the permission can still submit.

Both surfaces carry the digest. The inbox's input form gets a hidden turnDigest field exactly as the approval form has; the playground's awaiting_input payload (batch and streamed) emits it and submitInputAction reads it back, mapping StaleInputTurnException to a 409 that re-signals awaiting_input and SubmitterNotPermittedException to a 403.

What this deliberately is not 

  • Not an audit gate. approve() refuses an unrecorded decision only for a turn that DECLARES a write; an input-requiring turn declares none, so the INPUT event stays best-effort like every other event write. Adding a fail-closed audit gate here would strand harmless runs on a store hiccup.
  • Not a new grant. As in ADR-130/133: the tool gate and the backend user's own admin flag already carry the answer.
  • Not a change to who may reach the run. mayActOnRun() is untouched. A grant holder still reaches every suspended run; they are simply refused on the tools they could not run themselves.
  • Not a per-field verdict. The gate judges the turn, not individual submitted values. What the values contain is a content question that structural schema validation does not answer, and the ADR-105 admin gate on the playground surface remains its mitigation.
  • Not a production input tool. The catalogue is unchanged and InputPauseCoverageTest still pins an empty list. Its assertion is unchanged; only its rationale is, because the entry it guards is now a product decision rather than a latent hole. The gate is exercised by a TEST FIXTURE tool ( FakeInputTool , which gained a requiresAdmin flag).

Consequences 

  • InputSubmission gained a third constructor parameter. Optional in the signature for source compatibility; a null is refused at runtime, so every caller must supply it. Both in-tree surfaces do.
  • Two new request-validation exceptions join the AgentRuntimeException family: StaleInputTurnException and SubmitterNotPermittedException .
  • One new internal value object, PendingCallRefusal , carries the shared gate's verdict back to whichever path asked.
  • One new label, runs.error.submitterNotPermitted (EN + DE).
  • WaitingRunView::$turnDigest is now populated for input cards too. A test that asserted the opposite is reversed, and says so.
  • The null tool policy arm still switches the gate off, unchanged and for the same reason as ADR-133: it is unreachable from the container.

ADR-151: The context budget is a breakdown, not a number 

Status

Accepted

Date

2026-08-11

Amends

ADR-081 (a fifth RunStep kind, and therefore a fifth persisted event kind)

Authors

Netresearch DTT GmbH

Context 

ADR-107 bounds a transcript against the model's context window, and ADR-143 extended that bound to every configuration-driven send. Both produce a ContextFitResult: how many turns were dropped, the estimated total, the budget, whether it overflowed at the floor.

That is the verdict. It is not the reason. An operator whose agent run keeps losing history could read "dropped 4 turns, 7100 of 6946 tokens" and had no way to learn that 4300 of those tokens were a tool schema they could switch off, or a snippet block someone attached to the configuration last week. The fit's own log lines said the same thing in the same shape.

ADR-143's Revisit when named this: a consumer needing the fit's decision as data rather than a log line.

A second gap sits next to it. ADR-144 gave injected context a data class and built InputContextClassifier to fold the strictest declaration across a configuration's snippets and skills. Nothing renders it. The classification is visible only when the gate refuses a call — at which point the operator learns the answer by being blocked.

Decision 

The fit reports where the window went, as data. ContextFitResult gains a ContextBudgetBreakdown: the window, the reserved output, the safety margin, the budget, and four component lines — transcript, tool schema, system prompt, skills — plus the estimated total and what is left.

The lines close. The four components sum to estimatedTokens, and contextLength - reservedOutput - safetyMargin equals budget. This is not decoration: a breakdown a reader can subtract with must not drift from the figure the pruning decision actually used. So the components are derived from that one figure rather than measured a second time — the transcript line is estimated directly and the tool-schema line is the remainder, which is exactly the marginal cost of putting the schema block on the wire. Four independent estimates would each carry their own rounding and would not add up.

Snippets get no line of their own, and the label says so. ConfigurationSnippetResolver composes a configuration's tag-selected snippets INTO the effective system prompt (ADR-031) before the caller hands it to fit(). At the point the estimate is taken they are no longer distinct text. Two options were available: change what callers pass fit() so the block arrives separately, or report one line and label it honestly. The second was taken. The first means a ninth parameter on fit() with exactly one caller able to fill it, to split a figure whose components would then still be summed for every decision the manager makes — surface bought for a readout. The line reads "System prompt (incl. snippets)" everywhere it is rendered, and a test pins that the snippet block lands there and in no other line.

The surface is the playground inspector. It is where the run trace already lives, where the request step already shows what went on the wire, and where ToolLoopService already carried a note that a dedicated inspector step was the follow-up ADR-107 wanted. The accounting is recorded as its own RunStep of kind context, ahead of the request step it explains, and recorded even when the floor overflows and the run stops — that is the run whose operator most needs it.

A RunStep is a persisted event, so the vocabulary grows with it. Every traced run — the playground's, an interactive one, a queued one, a resume — goes through RunTrace::onRecord, which the AgentRunExecutor wires to AgentRunPersister::recordStep() (ADR-081). The context step is therefore written to tx_nrllm_agentrun_event like any other, and AgentEventKind gains a CONTEXT case: ADR-081 requires the stored kind to be one the enum declares, so that a reader can discriminate the payload. Recording it only for a handle-less trace was not available as a "playground only" gate — the playground run is a persisted run.

The classification is read from the gate's own service. The same panel shows every source the run injects — each snippet and skill by name, with the class it declared or none — and the effective, strictest class. InputContextClassifier::classify() is now the fold over InputContextClassifier::sources(), so the readout and the ADR-144 gate answer from one list and cannot disagree. Source NAMES only, never text: the classification exists because the text is sensitive.

The run, not the configuration. A playground run also injects the forced snippets and skills the operator ticked, and those reach the wire exactly like the configuration's own, so sources() takes them as arguments and the panel lists them. The gate does not see them: it asks classify(), which answers for the configuration alone. A forced source is therefore shown and not gated, and the panel is a superset of what ADR-144 enforces rather than a mirror of it. Widening the gate to the forced set is a decision about enforcement and belongs to ADR-144, not to a readout.

What this does not do 

It does not change the estimate. No component is measured differently, no threshold moves, no run is pruned that was not pruned before. This is a readout of arithmetic that already happened.

It does not correct the system-prompt over-count. ContextWindowManager::missingSystemPromptTokens() decides whether a prompt will be prepended by looking at $messages[0], while MessageShaper::applySystemPrompt() scans the whole list. A transcript whose system message sits deeper is therefore charged for a prompt that will not be prepended. That errs HIGH, which is the safe direction, and it stays. What changes is that the charge is now visible — a non-zero system-prompt line next to a transcript that already carries a system message. Making the readout of a known imprecision the occasion to change the imprecision would ship a behaviour change inside an observability PR.

Two of the four component lines are structurally empty on the surface this ships to, and the panel says so. The agent loop assembles its own prompt: it bakes the effective system prompt as message 0 before any fit, and it injects skill prose into the transcript with SkillInjectionService::augmentMessages(). By the time ContextWindowManager::fit() runs, both are inside the message list, so missingSystemPromptTokens() returns 0 with systemPromptInTranscript true, and no caller on that path passes an injected skill block at all. On the playground the system-prompt line therefore always reads "counted in the transcript" and the skills line always reads 0 — not because nothing is there, but because the transcript line already carries it.

The alternative was to make the two separable at the loop's seam: stop baking the prompt into the list and let the shaper prepend it after the fit. That changes what the loop assembles and in which order — the bake exists because a forced snippet system message would otherwise satisfy the manager's "a system message already exists" guard and suppress the configuration prompt for the run — so it is a behaviour change wearing an observability change's clothes, which the paragraph above refuses for the same reason. The lines stay, because they are part of the sum a reader subtracts with, and the panel states under the table why those two are empty here. They carry real figures for a send that injects either after the fit; fit() supports that and the unit tests pin it.

It is not RENDERED outside the playground. The context step reaches the AgentRun event stream — see the decision above; it is stored for every traced run — but no surface reads it back: the AgentRun module does not render the context kind, and the LlmServiceManager send-level fit (ADR-143) produces a breakdown that nothing displays, its overflow still surfacing as a log line. The consumer would be a run-history view, and that view does not exist yet. What ships now is the data, in the stream it belongs to, plus the one surface that reads it live.

It is not merged with the routing decision trace. ADR-142 raised the same surface question for routing, and ADR-143 said the two should get one answer. They still should — a single "why did this send look like this" panel covering model choice and window accounting. Both readouts are being built at once and against different data; converging them before either has a user would be designing the join from two guesses. The convergence point is named here so the next reader does not build a third.

Consequences 

●● An operator can see which component fills the window, per round, and act on the one that is theirs to change.

● The data-class declaration finally has a reader that is not a refusal. An operator can see that a configuration carries a CONFIDENTIAL snippet before a call is blocked for it.

ContextFitResult grew a required constructor argument — a BREAKING change for anyone constructing the result themselves, marked as such in the CHANGELOG. Tests/Unit/Api/api-surface.txt does not catch it: the snapshot records properties and methods and has never recorded a constructor, so what it pins is the new breakdown property, not the signature break. The CHANGELOG entry is the only place the break is stated. No default is offered, because the breakdown restates budget and estimatedTokens: a defaulted one would permit a ContextFitResult whose halves contradict each other, and every surface would then report "no accounting" for a fit that was measured. Construction is the manager's alone in production, and the closure rule made ContextBudgetBreakdown @api too.

◐ One extra estimator pass per fit, over the list that is about to cross a network. The fit already makes at least two.

◐ One extra recorded step per round on every traced run, not only the playground's. A recorded step costs what ADR-081/103/104 make it cost: one indexed findRun read for the cancellation probe, one guarded lease-renewal update for a leased segment, and one event row. That is roughly a third to a half more rows in tx_nrllm_agentrun_event per run. It is accepted rather than gated because the step boundaries it adds are the same ones the loop already has, the row is small, and a run whose accounting is missing from the persisted stream would make the eventual run-history view a second estimator — which is exactly what the closure rule above refuses.

✕ The four lines are estimates scaled by the manager's calibration factor, not tokenizer counts. ADR-107's limits are unchanged: a breakdown that adds up is not a breakdown that is exact.

Revisit when 

A run-history surface exists that needs the same accounting after the fact, or the routing readout has enough users to make the joined panel a real design rather than a guess at one.

ADR-152: An editor action is a declaration, not a second executor 

Status

Accepted

Date

2026-08-11

Authors

Netresearch DTT GmbH

Context 

Five tools write today (ADR-135, ADR-146). Each one is a narrow editorial act on exactly one record, executed through the DataHandler under the acting backend user, behind an approval pause and a write fence. As runtime objects they are complete. As things a HUMAN is offered, they do not exist at all.

Everything a tool declares about itself is written for the model. The name is a wire identifier (create_translation_draft). The description is a paragraph of English prose telling a language model when to call the tool and what it will refuse. The group is a bare string. None of it is translatable, and none of it is what you would put in front of an editor. The admin Tools module proves the point: it renders <code>{tool.name}</code> and the raw model-facing description, because that is all there is to render.

So "what is an editor action?" is an open question with two very different answers, and the answer decides how much of the runtime gets built twice.

Decision 

An editor action is a produced, narrowly-bounded WRITING TOOL. It is declared metadata on top of the existing tool contract, and it executes on the existing tool / agent-runtime path.

There is no EditorActionInterface::execute() beside ToolInterface::execute(). Two executors would mean two write paths, two fences and two audit stories — and the second one would be the one nobody hardened. Everything that makes a write survivable today is arranged around the tool path: the fence in AgentRunExecutor::trace() (ADR-141), the effect stamp on the run row (ADR-111), the implied approval a declared write carries (ADR-134), the preview produced at suspend (ADR-136), and the acting-user authorisation each tool performs itself (ADR-135). A parallel executor inherits none of that by construction; it re-implements it, or it goes without.

What is genuinely missing is not execution. It is the second, human-facing half of the declaration:

EditorActionInterface (opt-in)

Returns one EditorAction value object carrying a translatable label key, a short human description key distinct from the model-facing one, an icon identifier registered in Configuration/Icons.php, and the record types the action addresses, machine-readably. It is a marker-style optional interface exactly like ToolEffectInterface and ToolPreviewInterface: the read-only builtins are untouched, and a tool that does not implement it is simply not an editor action.

One method returning one object, rather than four getters, so the shape can grow without every implementor growing a method.

Its consumer ships in the same change
ToolAvailabilityService::editorActions() collects the declarations, and the Tools module renders the icon, the translated name, the human sentence and the record types — with the wire name demoted to a technical detail rather than removed, because that is the string an admin toggles by. A tool without a declaration renders exactly as before.
The declaration is collected by its OWN method, not by states()
ToolAvailabilityServiceInterface::enabledNames() is derived from states(), and the tool-call gate (ToolCallPolicy::decide()) reads it on every decision. Building the declaration there would run foreign code — EditorAction's constructor refuses an empty label key or an empty recordTypes — inside the runtime gate, so a third-party tool shipping a malformed declaration would abort tool calling for the whole run instead of rendering one row badly. editorActions() is therefore a separate method that only the module calls, and it drops a declaration that throws (logging it) rather than propagating: the row keeps its wire name, the module keeps rendering, the run is untouched. Three tests pin it — the state rows carry no declaration, a tool whose declaration throws is still listed by enabledNames() and still allowed by decide(), and the module renders it under its wire name beside a sound declaration.
recordTypes names the SUBJECT, not the written row

set_file_alternative_text declares sys_file: that is the uid the call names and the record an editor selects. The row it writes is that file's sys_file_metadata. A catalogue answers "what can I do with this record?", and only the subject answers it. create_translation_draft declares both pages and tt_content, because which one it addresses is the caller's choice rather than a property of the tool.

The rule is mechanical, and a test enforces it: every declared table must be one that a required argument of the tool's own spec can be filled from. create_content_element_draft therefore declares pages — its only required record identifier is page — even though the row it creates is a tt_content row. Declaring tt_content would offer the action on an element whose page a caller has no way to learn.

The rule bounds the subject, not every argument. move_content_element declares tt_content for its uid and still requires a target_page that no subject supplies; its human description says the target belongs in the editor's note, and the approval card shows the destination the preview resolved.

The group becomes an enum, and getGroup() stays a string 

A grouping of actions needs something to render, and a group had no name at all — only an identifier that happens to be an English word.

ToolGroup is an enum, not a value object, and it is deliberately not the return type of ToolInterface::getGroup().

  • The set of GROUPS is open. A third-party tool declares its own group — the recommended value is the providing extension's key — and both the allowed_tool_groups item provider and the egress policy already treat an unknown group as ordinary. Narrowing getGroup() to an enum would close a set that must stay open, and would break an @api interface to do it.
  • The set of groups THIS REPOSITORY SHIPS is closed, and was written out twice with nothing tying the two lists together: once as the per-group egress default (ADR-094) and once in the builtin-group test. An enum makes it exhaustive by construction — a case cannot exist without a label — and tests now tie the other two lists to it: one fails when a case has no egress default, the other when a builtin declares a group that is not a case. A third refuses a builtin the group test does not list, because a hand-maintained list that may silently omit a tool asserts nothing about it.
  • A value object would be a string wrapper accepting any value. That is exactly the openness the bare string already provides, so it would add a type without adding a guarantee.

A group outside the enum resolves to null and the module renders the raw identifier. A third-party group stays visible and toggleable; it simply has no translated name.

While in there: ToolInterface's own docblock listed the taxonomy and omitted editing — the group all five writers use, and the one this record is about. Fixed, and pointed at the enum.

What this record deliberately does NOT build 

bulkCapability
Not built. The approval unit is a turn: one digest, one verdict for all pending calls, and ADR-133 refuses a per-call verdict outright. The fence stamps ONE pending effect per run row. A bulk flag today would be read by nothing — and ADR-146's Revisit when is explicit that batching needs a different answer to "what did the approver agree to", not a bigger version of this one. A declaration nothing reads is worse than none: it reads as enforcement and buys false trust.
A caller-facing preview service, and a structured before/after diff
Not built. Both are real gaps. Today's preview is ToolPreviewInterface::previewCall() returning free prose, produced inside the loop at the moment of suspension, in the run's actor context, and re-authorised per viewer before the approval card renders it (ADR-136). A caller-facing service would need a second authorisation story, and a structured diff needs a renderer that knows what to do with it. Both belong with the UI that needs them, and neither is that UI.
A per-action grant
Not built. ADR-130 admits a grant only together with its consumer, and there is no consumer: enablement already cascades through the group gate, the per-tool gate and the per-configuration allow-list, and the approver gate is ADR-133's.
A sixth writer
The catalogue is complete. ADR-146 set the next review at a sixth writer; this record adds none.

Consequences 

✓ A writing tool can be named, described, illustrated and placed by record type without a change to how it runs. The write path, the fence, the approval pause and the audit are untouched: each of the five writers gained exactly one method returning a value object — no execute(), no guard, no argument, no fence was touched, and no executor or fence file was edited at all. Beyond the tools, the runtime files that changed are the collector, ToolAvailabilityService, and the module controller that renders what it collects. The collector's new method is off the tool-call path: enabledNames() and ToolCallPolicy::decide() never construct an EditorAction.

✓ The Tools module stops showing an administrator a wire name and a paragraph written for a language model where an editorial act was meant.

✓ The curated group taxonomy is enumerated in ToolGroup, and a case cannot exist without a label in both catalogues or without an egress default. It is not the only place the taxonomy is written down: the egress default is still keyed by string in ToolDataClassResolver, and the builtin-group test still names a group per builtin. Both are now tied to the enum in the direction that can go wrong — every case has an egress default, and every group a builtin declares is a case, the latter over every builtin rather than every listed one, because the list is now closed against the directory. The reverse is not asserted: an egress default for a group no case names is inert, and a case without a builtin is what a taxonomy looks like the day before its tools land.

✕ The declaration is metadata and cannot be enforced. A third-party writing tool that does not implement the interface is still a write — the runtime's write axis is ToolEffect and nothing about this record changes that. This is deliberate: making the declaration mandatory would be a breaking change to ToolInterface for a benefit that is presentational — and presentational it stays, right down to a broken declaration costing its row a decoration and nothing more.

✕ Three surfaces render a group name and two of them keep rendering the raw identifier: the allowed_tool_groups TCA select and the Playground's grouped tool checkboxes. The select is a FormEngine item list rather than a template and its labels are stored operator selections; the Playground list is a picker for one run rather than the administrative catalogue. The Tools module is the consumer this record ships; both others are a one-line change whenever they are next touched.

✕ The label keys live in PHP rather than beside a template default, so a missing key renders as nothing at all. A test resolves every declared key in both the English and the German catalogue for exactly that reason.

Revisit when 

An Editor Action Center exists — a surface that offers these actions on a selected record rather than merely listing them. That surface is what will demand the structured before/after diff and, if it ever offers more than one record at a time, the answer to "what did the approver agree to" that bulkCapability would need first.

Also revisit if a third party ships a writing tool. The declaration is opt-in today because five of five writers are ours; the first foreign one is the evidence for whether "opt-in metadata" or "part of the write contract" is the right place for it.

ADR-153: A run's uuid is the correlation id of everything it does 

Status

Accepted

Date

2026-08-11

Amends

ADR-058 (telemetry rows gain a run to belong to), ADR-081 (the persisted run gains a read surface)

Authors

Netresearch DTT GmbH

Context 

An agent run made N provider calls and produced N unrelated traces. ProviderCallContext::for() , ::forConfiguration() and ::forService() each minted a fresh Uuid::v4() internally, and a caller had no way to pass one in — only the raw constructor and the with*() copies preserved an existing id. So a five-round run wrote five tx_nrllm_telemetry rows that nothing tied together, and nothing tied any of them to the run.

tx_nrllm_governance_event was worse: it HAS an agentrun_uid column and no code ever wrote a non-zero value. All three write points passed 0. The column was a declaration with no writer, and the comment above its correlation_id sibling claimed the id linked a row "via the run's correlation, to its agent run" — which was never true, because runs had no correlation.

The read side of a run timeline already existed and was unused: AgentRuntimeInterface::events() and ::status() are implemented and authorised ( AiActorContext::mayActOnRun() with ServiceAccountScope::AGENT_READ ) and had zero callers in Classes/.

Decision 

No new column: the run's uuid IS the correlation id. tx_nrllm_agentrun.uuid and tx_nrllm_telemetry.correlation_id are both an RFC 4122 uuid in a varchar(36). A correlation_id column on the run would have stored a second identifier for the same thing and needed a mapping nobody asked for. AgentRunReference::correlationId() states the equality in one place; the join key already existed on both sides.

The context is widened, not rewritten, and only where a caller exists. ::forConfiguration() takes an optional ?string $correlationId; null — every caller that has no wider trace — mints per call exactly as before. An EMPTY string also mints: '' is the "no trace" marker an unpersisted run leaves behind, and adopting it would collide every such call into one bucket.

::for() and ::forService() were deliberately left alone. No agent-run path reaches either — a run drives a configuration — so widening them for symmetry would add an argument nothing passes, which is the shape this project refuses. Widen them when a run driving a configuration-less or a specialized-service call exists; it is the same three lines.

The run travels on the execution context. ToolExecutionContext is already built once per run, from the run's actor, and already reaches the loop, the resume paths and the tool gate through one parameter. Adding the run there kept ToolLoopServiceInterface — three methods, one of them thirteen parameters long — unchanged.

The uid travels as pipeline metadata. agentrun_uid is an int a middleware needs, which is what the metadata map is for (beUserUid, idempotencyKey, the cache key). CallMetadataFactory::agentRun() produces the key GuardrailMiddleware::METADATA_AGENT_RUN_UID , disjoint from the other three producers so the + merge at every call site keeps working.

All three governance write points are attributed, and 0 keeps a meaning. The tool gate reads the run off the execution context; the guardrail middleware reads the uid off the metadata; the input-context gate is handed it by the manager, the same way it is handed the backend user. 0 now means "this decision did not happen inside a run" — a plain provider call, or a bare ToolLoopServiceInterface consumer driving the loop without persistence — rather than "the identity was available and dropped".

Of the three, the input-context gate is the one that also keeps correlation_id = '' by construction: it runs BEFORE the ProviderCallContext exists, so there is no trace id to write. Its agentrun_uid is the join key instead.

The view renders metadata, and only metadata. AgentRunController::showAction() goes through the runtime — so the authorisation is the runtime's and an unreadable run is indistinguishable from an unknown one — and RunTimelineFactory widens the released run with the telemetry rows carrying its correlation and the governance rows carrying its uid or correlation. What a step contributes is an ALLOW-LIST of non-content payload keys. RunStepPrivacyFilter already drops content at the default level; the allow-list means an installation running at REDACTED or FULL does not silently turn this page into a transcript viewer. suspended_state and queued_request — stored verbatim, bypassing the filter — are never assigned to the view; AgentRuntimeInterface::status() strips them before the controller sees them.

The view is read-only. No approve, no retry, no cancel. Those exist on the inbox list, where they are authorised per run and per turn.

The link is offered only where the read would succeed. The inbox list is deliberately wider than the read: an approval-grant holder sees every user's run, because AiActorContext::mayActOnRun() grants the human equivalent of ServiceAccountScope::AGENT_APPROVE and of no other scope (ADR-130). Read therefore stays owner-or-admin, and offering the row a Timeline link that can only redirect back would be an affordance for an authorisation nobody holds. TerminalRunView::$openableByViewer asks the same mayActOnRun() the controller will ask, so the two cannot drift; widening the read to the approval grant would be a change to the runtime, not to the template.

Consequences 

✓ One run, one trace: its rounds, the synthesis completion, the fallback hops inside them and the governance decisions taken along the way all resolve from the run's uuid.

tx_nrllm_governance_event.agentrun_uid has a writer and a reader in the same change. So does the timeline the read surface was built for.

✓ No schema change to tx_nrllm_agentrun. The one DDL addition is an index (agentrun_uid, crdate) for the query this record introduces.

◐ Streaming is not correlated. It bypasses the pipeline (ADR-062) and settles its own telemetry; a streamed run's rows still carry a per-call id. Nothing in this record blocks it — the dispatcher takes the same reference — it is simply not wired.

◐ A row written before this change keeps its per-call id and agentrun_uid = 0. Historic runs therefore show their steps but no calls; there is no backfill, because nothing recorded which call belonged to which run.

✕ The timeline orders by crdate (second resolution) with the step sequence as the tiebreak, so a call and the step that made it land in the right order but two calls within one second only order by insert order. Sub-second ordering would need a column the log tables do not have.

Revisit when 

The streaming path needs the same attribution, or the timeline needs sub-second ordering — the latter is a column change to two append-only tables and a purge window's worth of mixed data.

ADR-154: An MCP server's liveness is observed, not inferred 

Status

Accepted

Date

2026-08-11

Context 

ADR-116 moved the MCP client into nr_llm and closed with a promise: the public surface that lands with it "will carry its own ADR when the implementation is designed". That record was never written. This is it, scoped to what the implementation actually grew — the operator-facing surface of the MCP client, not a general API freeze.

The gap it closes is narrower and more embarrassing than an API question. Before this change, tx_nrllm_mcp_server stored import_status, import_error, last_imported and tool_count, and nothing else about the far side. All four are written in one place, McpServerRepository::recordImportOutcome() , called only by McpImportService . So:

  • A server that has been answering tools/call for six weeks reads as untouched since whenever its catalogue was last imported. Nothing in the installation measured, stored or displayed that it answered.
  • The only way to find out whether a server was reachable was to run the catalogue import — the one action that also rewrites the catalogue and can orphan tools. "Is it up?" and "re-read what it offers" were the same button.
  • import_status declared a value, importing, in TCA and in both language files that no code path has ever written.

An operator debugging a failing agent run therefore could not distinguish "the server is down" from "the server is fine and the tool was renamed" without performing a write.

Decision 

  1. Two columns, written on every successful client round trip. last_contact and last_latency_ms on tx_nrllm_mcp_server. Any completed operation stamps them: a tool call, a catalogue import, a connection test. They answer "when did this installation last get an answer out of this server, and how long did it take" — which is a different question from "when was its catalogue last read", and needs its own storage because the existing column cannot be widened without lying about imports.
  2. The seam is the client, not the transport and not the repository. McpHttpTransport sees every round trip — and one operation is three to fifty-two of them, because each opens with a handshake and a catalogue walk pages. A transport that recorded would either write per round trip or keep mutable per-server state on a DI singleton, which is the exact shape its own docblock warns against for the authenticated client. So the transport measures and returns durationMs; McpClient owns the operation and records once, with the latency of the round trip that completed it (the last catalogue page, the tools/call, the handshake). A failed operation records nothing: half a catalogue walk is not a contact.
  3. The write cannot fail the call. McpHealthRecorder wraps the repository, pins the timestamp from the request context and swallows every Throwable into a warning. This sits behind a tool call that has already succeeded and whose answer a backend user is waiting for. A locked table or an unmigrated column must cost a stale timestamp, never the answer. The functional test asserts exactly that, with a connection pool that throws.

    It writes two columns and no more. tstamp stamps an operator edit and is left alone — bumping it on every tool call would make the record list report a change nobody made. import_status is left alone in the other direction: reaching a server says nothing about whether its tool list is current.

  4. A connection test that writes no catalogue. McpClient::ping() performs the initialize handshake, sends the readiness notification the protocol requires, and stops. It reports reachability, the latency, the protocol version the server chose and the server's self-description. It is reachable from the module over its own admin-gated AJAX route, beside the import.

    Only the latency is stored. The other three live exactly as long as the response that carries them, which decides how they must be shown — see decision 5.

    A failure is returned, never stored. import_error holds the reason the last import failed; a probe overwriting it would replace a diagnosis with a different diagnosis, and an operator would lose the one they were working on. A success is stored, because a successful handshake is a contact like any other.

    The probe does not require a declared data class, unlike the import. A data class governs what a server's tools may see; a handshake classifies nothing and returns nothing. Refusing to probe an unclassified server would make the first thing an operator wants to check the one thing they cannot.

  5. The module is the reader. The MCP Servers module shows, per server: enabled state, last successful contact and its latency, transport, authentication mode, tools discovered, declared data class, approval requirement, last import and the last import error. Without a reader the two columns would be a declaration nothing consults.

    The connection test's report is rendered into the card, and that action alone does not reload the page. Every other action in these list modules POSTs and reloads, because what it changed is in the row. This one is not: three of the five things the probe reports are stored nowhere, so they exist only in the response, and a reload — which the shared helper runs in the same tick as the success callback — would destroy them before they could be read. The alternative is decision 4 in reverse: store the protocol revision and the self-description so a reloaded page can show them. That is columns and a migration for two facts an operator looks at once, and it would make a hostile server's self-description persistent instead of transient. So the module writes the report into the server's card, together with the refreshed contact line that a reload would have brought. Both strings are composed in the controller — the first render and the in-place update read the same one, so they cannot drift — and the front end writes them with textContent, because part of what they say was written by the far side. A refusal is written into the same region, and the region is cleared before the request goes out: without that, a probe that stops answering leaves the last success standing for the fifteen seconds of the transport timeout and then beside its own error toast. The corollary is that the report is gone on the next page load, which is correct: it describes one moment, and what outlives the moment is the contact date and its latency.

    Transport is stated, not stored. HTTP is the only transport this client speaks (ADR-116, "Transports: HTTP only") and a column would imply a choice the operator does not have. The readout still says it, because it is the first question asked of a server that will not answer.

    Authentication is shown as configured, not as resolved. The transport falls back to a bearer placement for any value it cannot parse, and the column is a plain varchar the TCA select only constrains in FormEngine. So the module labels the two placements TCA offers — bearer and header — and prints any other stored value verbatim, marked as one it does not offer: if an operator typoed it, seeing the typo is what lets them fix it, while seeing the fallback would tell them the configuration is fine.

  6. ``importing`` is removed rather than written. The import runs to completion inside the request that starts it — there is no queue, no worker, no second reader. No caller could ever observe the state, so writing it would be a second UPDATE per import for nobody; and a request that died between the two writes would leave a row stuck in importing for ever, with no reaper to clear it. The value is gone from TCA and from both language files. It was never in ext_tables.sql, which only carries the never_imported default.

Consequences 

  • McpClient takes a second constructor argument, McpHealthRecorderInterface . Required rather than nullable: an optional health recorder is one that a mis-wired container silently drops, and the axis would fail open with every test still green. The interface exists so the unit tests can observe recording without a database; the database claim is asserted in the functional suite against real rows.
  • McpHttpTransport::call() returns a third key, durationMs.
  • ModuleAction.js gains post(), the half of postAndReload() that does not navigate; postAndReload() is now written in terms of it. The connection test is the first action in these modules whose answer is the response rather than the reloaded page, and the reload and the reporting callback could not both stay in one helper. post() also takes an onFailure callback, for the same reason onSuccess exists: a caller that paints the answer into the page has to paint the refusal there too. The re-enabling of the triggering button stays where it was for the six existing consumers — failure only — because a button live again while its reload is pending can fire the same state-changing POST twice; post() re-enables on success as well, since nothing navigates away from it there.
  • Both AJAX actions of McpServerController now resolve the caller and the named server through one private helper. The admin gate and the "a uid, not something a cast would accept" validation are one implementation, reached by both, because both reach an external party on an administrator's behalf.
  • The new classes are marked @internal under ADR-127. What this record freezes is the operator surface — two columns, one route, one readout — not a PHP API. The public-service count authority (ADR-101) is unaffected: nothing here is registered public.
  • ToolCallPolicy 's remote-tool branch — a RemoteToolInterface tool is refused above the trust-zone ceiling even in observe mode (ADR-115, quoted in ADR-140) — gains a direct unit test. It was previously exercised only indirectly through the governance readout's fake tool, so the load-bearing || could have been deleted with the policy's own suite still green.

Deliberately not decided here 

Each of these is a separate decision, and none is blocked by this one:

  • Retry, backoff and circuit breaking. A latency number is a measurement; a breaker is a policy about when to stop calling, and it needs an owner for the half-open state and an answer for what an agent run mid-loop should do.
  • Cancelling an in-flight call. The 15-second transport timeout is still the only bound.
  • Per-tool data class overrides. The class stays a property of the server (ADR-094).
  • Non-HTTP transports. ADR-116 rules stdio out on purpose and dissolves sse into HTTP framing.
  • MCP resources, prompts and sampling. This client declares no capabilities precisely so a server cannot invite it into any of them.

ADR-155: The system prompt carries a declared data class 

Status

Accepted

Date

2026-08-11

Amends

ADR-144 (which of the injected sources are classified)

Authors

Netresearch DTT GmbH

Context 

ADR-144 classified two of the things a configuration-driven send injects — the snippets it composes and the skills attached to it — and declined two others in these words:

The system prompt and the task input are deliberately not classified: neither has a per-record home for a declaration — a system prompt is a field on the configuration that already knows its own provider, and task input is whatever the caller passed this second.

The system prompt half of that reasoning was true of a FIXED-mode configuration and only of one. Its provider is named on the record; a class declared beside the prompt could not constrain anything the operator had not already decided by choosing the model. A declaration nothing reads is worse than none, so it was right to leave out.

A criteria-mode configuration knows no provider. The model is chosen at call time, and until ADR-149 the gate could not even read the zone of the one that was chosen. Now that it can, the missing declaration has a consumer: a class on the system prompt says which models the configuration may resolve to. That is issue #724.

Decision 

One column, the ADR-144 shape. tx_nrllm_configuration gains system_prompt_data_class, a ToolDataClass value on the scale snippets, skills and tool output already share. Empty means UNDECLARED and constrains nothing, so every configuration that exists today keeps reaching every provider it reached — the migration risk was never the switch, it is guessing a value for data that already flows, and nothing here guesses.

Task input stays unclassified. ADR-144's second argument is untouched by any of this. The accepted input is a runtime string the caller passed this second; there is no record to declare on and no operator to declare it. tx_nrllm_task gains nothing.

The class classifies the TEXT. A configuration with an empty system_prompt declares nothing whatever the column says, because there is no prompt to protect and a refusal would name a source the operator cannot find in the form.

This is deliberately not the reading a snippet gets, and the asymmetry is worth naming rather than glossing. A snippet that is selected but empty still constrains: PromptSnippet::getDataClassEnum() is a bare ToolDataClass::tryFrom() with no text check, and ConfigurationSnippetResolver::selectedSnippets() filters on hidden and duplicate only. The two differ because selection differs. A snippet is selected by a tag match — an operator who attached it meant it, and an empty one is a snippet whose text has yet to be written, not a source that is absent. The system prompt is a field on the record being sent: blank means the configuration contributes no text of its own, and there is nothing for the class to describe.

The consequence is small and one-directional: an operator who classifies a configuration and then blanks its prompt loses the constraint silently. Nothing leaks — a blank prompt sends nothing — but the column keeps a value the gate no longer reads.

Where it binds, and why not the other place 

The input-context gate, in the same fold as the other two sources. InputContextClassifier::classify() reads the configuration's declaration alongside the snippets and the skills, the strictest still decides, and InputContextTrustGate refuses the send. No new gate, no new switch, no second implementation of the question.

The honest alternative was routing eligibility — teaching EligibilityEvaluator to reject a model whose provider sits below the declared class, so the configuration resolves to a permitted model instead of being refused. It is a nicer outcome when it works. It is the wrong place, for three reasons:

  1. It would invert the dependency ADR-149 just fixed. That record states the invariant plainly: the zone follows routing, it must never drive it. A governance declaration that filters candidates makes a data class decide which model serves a call. The gate reading the zone off the chosen model, and the router choosing without consulting the gate, is one direction of dependency; binding at eligibility makes it a cycle.
  2. It would not cover fixed mode. ModelSelectionService::resolveModel() returns the named model without consulting EligibilityEvaluator at all when the configuration is not in criteria mode. A declaration that binds only in criteria mode would be silently inert on the majority of records — the exact failure ADR-144 avoided by not shipping the column at all.
  3. It would be the second implementation. The gate still runs afterwards and still asks whether the declared class fits the zone. Two places answering one question drift, and the drift is invisible until one of them permits what the other refuses.

The cost of choosing the gate is stated rather than hidden: a criteria-mode configuration whose criteria match both a local and an external model may resolve to the external one and then be refused, where an eligibility filter would have picked the local one and succeeded. That is a worse outcome for the operator and a correct one for the invariant — the refusal names the source and the zone, so the fix (narrow the criteria, or lower the declaration) is in the message.

Consequences 

✓ An operator can declare that a configuration's system prompt must not leave a trust zone, and on a criteria-mode configuration a send that resolves to a model in a weaker zone is refused.

✓ Nothing new to learn: same scale, same enforcement switch (tools.dataClassEnforcement), same observe mode, same audit decision (context_blocked), same rule that the refusal names the source and never the text.

LlmConfiguration is @api, so its three new accessors extend the public surface. Additive only — no existing signature changes.

◐ A configuration that declares a class and later has its prompt cleared stops constraining anything, with no warning. That follows from classifying the text rather than the record, and the alternative — a refusal citing a field the operator sees as empty — is worse.

✕ Still classifying SOURCES, not content. A system prompt declared publicContent that in fact carries a credential is not caught here; that is the guardrail screener's job (ADR-087) and it runs regardless.

✕ The fallback hops are unchanged, as in ADR-149: a fallback configuration contributes the zone of its own relation, so the declaration is judged against a chain that is still read the old way.

Revisit when 

Per-call injected context needs classifying — context a caller hands the send rather than anything a record declares. That is issue #731 and a different decision: it has no per-record home either, and whether an argument may carry a declaration is the question ADR-144 answered "no" for task input.

Also revisit if routing gains a governance-aware pre-filter for some other reason. The three arguments above are about adding one FOR this; if one arrives anyway, the eligibility option becomes cheap and the trade above should be re-read.

ADR-156: Persist the routing decision, and observe complexity without routing 

Status

Accepted

Date

2026-08-11

Amends

ADR-142 (both of its deferrals are lifted, one of them only halfway)

Authors

Netresearch DTT GmbH

Context 

ADR-142 deferred two things and said exactly why.

The decision trace, on a condition: "a trace whose only reader is a future analytics view is a declaration nothing reads". ADR-148 then built a reader — the Governance tab explains what a configuration would resolve to. It recomputes live and reads nothing persisted, so it cannot answer the question an operator actually arrives with: this call, yesterday, ran on model A. Why?

Complexity routing, for want of evidence: "routing on an estimated complexity score needs evidence that the score predicts anything, and that evidence does not exist yet. Measuring it first, deciding later, is the same shape ADR-138 used". Nothing has been measured since, so the evidence still does not exist — and it never will until something writes the numbers down.

tx_nrllm_telemetry (ADR-058) already holds one immutable, prompt-free row per provider pipeline run, and already carries which model served (served_model) and whether a fallback ran (fallback_attempts). What it cannot say is why that model, or how big the request was.

Decision 

Two column groups on the one table, and one reader for both.

The decision summary is six scalars, not the decision. RoutingDecision holds Model entities, per-candidate scores and per-signal floats. What is persisted is RoutingSummary : the policy mode, the candidate count, the distinct rejection reasons, and three booleans saying whether quality, health and cost actually moved this decision. The candidate MODELS are deliberately not stored — which models exist and which lost is a catalogue question the Governance tab answers against the live catalogue, and a per-request copy would grow the log by the size of the model table and go stale on the next rename.

"Signal used" means it moved the decision, not that the mode weighed it. A signal counts as used when it carried weight in that mode AND at least one eligible candidate had a measured value. Each half alone over-reports.

CandidateRanker contributes neither value nor weight for an absent signal, so a decision taken in quality mode against a catalogue nobody has scored ranks exactly as providerPriority would. "The mode was quality" and "quality decided anything" are different facts, and only the second one explains the outcome.

The mirror case is why the weight is asked for rather than inferred from what the ranker collected: CandidateRanker::signalsFor() collects cost whenever the criteria set preferLowestCost, but quality weighs cost at 0.0 and score() skips a zero-weight signal. quality + preferLowestCost — both operator-settable — would otherwise record a cost signal that moved no score. The cost TIEBREAK in that combination is a separate mechanism (the criteria ordering equal-scoring candidates by raw cost) and is deliberately not reported as a signal.

One evaluation, two consumers. ModelSelectionServiceInterface::resolveModelForCall() returns the model AND the summary; resolveModel() is that method with the reasoning dropped. The alternative — resolve, then call explainRouting() to learn why — would run discovery, eligibility and ranking twice on every criteria-mode request, and a model toggled between the two runs would make the recorded reason describe a decision that never ran.

The scratchpad is the channel, and both write sites use it. The summary and the complexity are recorded onto TelemetrySignals , the one object that survives the pipeline unwind. TelemetryMiddleware::safeRecord() and StreamingDispatcher::recordTelemetry() both read it — the pair ADR-058 established, and moving only one of them would make streamed runs a silent hole in the trace.

A run that chose nothing writes that it chose nothing. routing_policy_mode is '' for fixed mode, for the service paths that resolve no configuration, and for every row written before these columns existed. Zeros would read as a decision that considered no candidates. The reader filters on the empty mode in SQL rather than after the fact, because the row limit is applied in the query: on an installation whose traffic is mostly fixed-mode, filtering afterwards would fill the window with rows the page drops and report a period's real decisions as none.

Complexity is measured and nothing more 

Six figures per request: a 0-100 structural score, the payload size, the token estimate, the tool count, the context utilisation and the request shape. All six are shown in the readout — a column nothing reads is the thing ADR-142 refused, and "raw evidence for a later sample" is not a reader.

The reader is the routed-call table, so it shows them only for routed calls. The complexity group shares the row with the routing group and is filtered with it: the reader narrows on routing_policy_mode != ''. Complexity, though, is written on every configuration-driven send, fixed-mode ones included. On an installation whose configurations all name their model, these six columns are therefore written and read by nothing until a criteria-mode call appears. That is deliberate rather than overlooked: the activation criteria below ask whether complexity predicts anything about the DECISION, so the population that matters is the one the decision filter keeps, and a second table narrowed on complexity_shape != '' would answer a question nobody has yet. It is also the honest limit of ADR-142's condition — met for criteria-mode traffic, not for all of it.

A row can carry a decision and no measurement. complexity_shape is '' wherever nothing measured, and the readout asks that flag ( RoutedCall::isComplexityMeasured() ) instead of rendering the column defaults as a send that scored zero on everything. A criteria-mode embeddings configuration is the combination that reaches the table: it resolves a model, so its row passes the filter, but it runs no context fit, so nothing calls the estimator.

Nothing routes on them. There is no new signal in CandidateRanker , no weight in RoutingPolicyMode , no predicate in EligibilityEvaluator , and no opt-in flag that would add one. The estimator's only consumer is a telemetry column.

What has to be true before anything may route on this. All three, measured over a sample of real traffic, not argued:

  1. Cheaper models hold for simple requests. Low-score requests served by a cheaper model complete successfully at a rate indistinguishable from the model that would otherwise have served them.
  2. Quality does not degrade. On the same low-score population, the measured quality signal (ADR-060) for the cheaper model is not worse than for the incumbent.
  3. Real cost drops. The recorded cost for that population falls by enough to be worth a routing rule — a rounding-error saving buys a permanent branch in the decision path.

Fail any one of them and the correct outcome is to delete the idea, not to weaken the criterion.

The score's weights are judgement, not calibration — the same admission ADR-142 makes about the ranking weights. Three capped terms (conversation turns, tool count, context utilisation) summing to at most 100. Nothing depends on the exact numbers precisely because nothing routes on them; if the evidence says a different shape predicts better, changing them breaks no behaviour.

Bytes, not characters. The payload size is strlen() , and the field is named payloadBytes to say so. The token estimator this shares a call with counts bytes too (ADR-121); a second unit here would make the two figures incomparable.

The payload size is the one size figure that is always there: it needs no context fit. That is what earns it a place in the readout rather than only in the table — on a send where no fit ran, the token estimate and the utilisation are NULL and the byte count is all the page can honestly show.

Context utilisation comes from the fit (ADR-143) — the only place that knows the token estimate and the model's budget together. Where no fit ran, the token and utilisation figures are SQL NULL rather than zero: an unmeasured send is not an empty one. The utilisation is not clamped at 100, because above 100 is the overflow ADR-143 exists to report.

What this does NOT do 

No per-component context breakdown. Utilisation is one number, estimated tokens over budget. A sibling workstream is adding a per-component breakdown to ContextFitResult (which share of the window is system prompt, transcript, tool schemas). When it lands, the complexity_context_percent column is the place it should converge: the breakdown answers what filled the window, this answers how full it was, and the second is the aggregate of the first. Nothing here forecloses that — the reader shows one number and would show more.

No routing on complexity. See the criteria above.

No prompt, no response, no criteria echo, no model list. The table's prompt-freedom is structural — the DTO has no field for any of them — and stays that way. Everything added here is a count, a size, an enum name or a boolean.

No retention change. The new columns live inside the existing row and are purged with it by nrllm:telemetry:purge.

Consequences 

✓ "Why model A for this call" is answerable after the fact, not only for a hypothetical. The Governance tab shows both halves on one page.

✓ ADR-142's condition for persisting the trace is met by construction: the reader ships in the same change as the writer.

✓ The complexity question can now be settled with data instead of opinion, and the bar for acting on it is written down before anyone has a stake in the answer.

◐ Twelve columns on a high-volume table. They are scalars, and eleven of the twelve are ints or short enums; the row was already the widest thing this extension writes per request.

◐ The routing summary costs one object allocation per criteria-mode resolution, and nothing else — it is read off the decision that was already computed.

✕ The score is uncalibrated. It is a number to correlate against, not a number to trust.

✕ A fixed-mode installation records no decisions and sees an empty table. That is correct — nothing was chosen — but it does mean the readout is silent exactly where an operator might most want reassurance that routing is not happening.

✕ On that same installation the complexity group is write-only. It is measured on every configuration-driven send and the only reader drops the rows, so the columns cost storage and answer nothing until a criteria-mode configuration exists. Giving them a reader of their own is the fix if that ever matters; it is not worth a second table before it does.

Revisit when 

The three activation criteria have been evaluated against a real sample — whichever way they come out. A "no" is as much a result as a "yes" and should be written into this record rather than left for the next person to re-derive.

Also revisit when the per-component context breakdown lands, to fold it into the same column group rather than beside it.

ADR-157: The simulation covers the run, and answers for an actor 

Status

Accepted

Date

2026-08-11

Amends

ADR-145 (the simulator gains the remaining gate, an actor, and an audit decision)

Authors

Netresearch DTT GmbH

Context 

ADR-145 built a simulator on the Governance tab: pick a configuration and a tool, and the page runs ToolCallPolicy::decide() — the call the runtime makes — and renders the answer.

It closed with two open items, both stated as consequences rather than as defects:

  • the simulator covers the tool gate, and the input-context gate (ADR-144) "is answerable the same way and is not wired in yet". ADR-148 wired in the routing decision as a separate readout section, which answers "why this model" but does not participate in the simulator's verdict;
  • the simulator answers for the operator running it, and "would this be allowed for an editor" needs a user picker, "which is a separate surface".

Both matter because a run is stopped by whichever gate refuses FIRST, and a page that says Allowed while the input-context gate would refuse the send, or while routing resolves no model at all, is not a partial answer. It is a wrong one, given to an operator who came to the page precisely to avoid guessing.

Decision 

One verdict, four axes, every axis visible. The simulation asks the tool gate, the input-context gate, routing eligibility and the approval requirement, and folds them: ALLOW when every axis permits and no human decision is needed, ALLOW + APPROVAL when a human decision is, BLOCK when any axis refuses. Each axis keeps its own row, because the fix differs per axis — a tool group, a data class, a model catalogue, an approval workflow — and a verdict that could not say which gate decided would send the operator looking in four places.

ALLOW + APPROVAL is a third outcome rather than a footnote on ALLOW. A call that runs only after a human says yes is not the same as one that runs unattended, and collapsing the two would make the approval axis invisible at exactly the moment it decides.

The input-context gate gets a decision method, and the enforcement path uses it. InputContextTrustGate::decide() resolves, compares and returns an InputContextDecision ; assertPermitted() calls it, records the governance event and throws. One rule, two callers.

Catching the exception in the simulator was the obvious cheap alternative and is WRONG. Observe mode does not throw at all: for a configuration the runtime records as context_blocked and lets through, a catch sees nothing and reports "allowed". The decision object carries zoneRefused and enforcing separately, so "the gate refused" and "the send proceeded" are both sayable.

An undeclared configuration reports a null zone and a null ceiling rather than the values it would have resolved. Nothing was compared, so nothing is claimed — the same discipline RoutingReadout applies to fixed mode. It also keeps the gate's early return: resolving a zone walks the fallback chain through the repository, and the hot path must not pay for a comparison it never makes.

The approval predicate becomes one resolver with three callers. ToolApprovalRule::requiresApproval() is what ToolLoopService 's approval scan, ToolRegistry 's boot validation and the simulator all ask. The two copies it replaced had already drifted: the registry exempted every RemoteToolInterface , including one carrying the RemoteApprovalInterface declaration the loop honours — so a remote tool the loop would suspend for approval was still registrable alongside RequiresInputInterface , which is exactly the deadlock that check exists to prevent. The shared rule closes it. No shipped tool implements the combination, so nothing that registers today stops registering.

The actor is a backend user, resolved read-only. The picker offers the backend users the rest of the backend offers, and the selection is resolved through ActingBackendUserResolverInterface — the seam a queue worker already uses to authorise for the user who queued its work (ADR-083). Privilege is read from the fresh database record, so the picker can lower privilege but never mint it. There is no session switch, no execution as the user and no write of any kind.

ToolCallPolicyInterface::decide() is unchanged. It is @api and takes a raw BackendUserAuthentication ; widening it to take an AiActorContext would be a breaking change to make a readout convenient, and the resolver already produces exactly what the gate takes.

Three of the four axes are global, and the page says so. Routing reads the model catalogue through ModelRepository::findActive() , which ignores enable-fields and takes no user; the input-context gate compares a configuration's declared classes against the trust zone it can reach; the approval requirement is a property of the tool's declaration. Only the tool gate reads the actor, through requiresAdmin(). The readout carries a scope column stating this per axis. A simulator that answered identically for every actor on three axes without saying so would imply a dimension that is not there, which is worse than not offering the picker at all.

The picker offers users, not usergroups. A group is not resolvable to an acting backend user, and no axis in this simulation reads group membership on its own: the tool gate reads isAdmin(), and a real user brings their groups with them from the database. A group entry would have been a control with no reader.

A simulation is not audited. This is the decision the record owes an explicit answer, because either choice is a behavioural difference. The runtime writes a GovernanceEvent when it BLOCKS a call. A simulation blocks nothing, so recording one would put rows into the audit for calls that never happened, and the audit's only load-bearing property is that every row is something the installation actually did. An operator reading context_blocked rows to decide whether enforce is safe (the workflow ADR-113 and the administration guide describe) would be counting their own experiments.

The cost is real and is not hidden: "who checked what, and when" cannot be answered from the audit. The module is admin-only, the simulation performs no inference and spends no tokens, and every gate it asks is read-only — so the action being unlogged grants nothing. If a future requirement needs it, the answer is a separate simulation log with its own retention, not rows in the governance stream that the enforcement workflow reads.

The simulator lives in the tool module. Core may not import Service\Tool (ADR-090, enforced by ModuleSeamTest), and the thing being simulated is a tool call: the entry point takes a tool name and half its collaborators are tool-module classes. The two core gates it also asks are a dependency in the allowed direction.

Consequences 

✓ An operator can answer "would this specific call pass the tool gate, the input-context gate, routing and the approval check, for this specific person" in one place, and see which gate decided.

✓ The approval requirement has one definition. Narrowing the remote exemption is one edit instead of three kept in step by a comment.

✓ The input-context gate can be asked without being triggered, and observe mode is reportable rather than invisible.

✓ No second policy engine, and no widened @api signature. Every axis is the runtime's own service.

◐ The simulation does not resolve a serving model, so it asks InputContextTrustGate::decide() without one and gets the zone the configuration's own relation gives. For a criteria-mode record that is the fail-closed EXTERNAL_GLOBAL (ADR-149), while the runtime — which has resolved a model by then — may read a weaker declaration as permitted. The page is therefore stricter than the send it describes, never laxer: it can warn about a refusal that will not happen, and cannot miss one that will.

◐ The verdict is a fold of four axes, evaluated together. The runtime evaluates them at different moments in a run, so a real call that fails the tool gate never reaches routing. The page reports all four regardless, which is more than the runtime would have said — deliberately, because an operator fixing one refusal wants to know whether the next one is waiting behind it.

◐ Routing is asked for a tool-calling operation, because that is the run a tool simulation describes. A configuration used for something else may route differently, and the readout below the simulator is where that is asked.

✕ Configuration access is not one of the four, and it is actor-scoped today. ConfigurationResolver::actorMayUse() (ADR-070) reads backendGroupIds and refuses a group-restricted configuration for a non-member; the picker's configuration list is unfiltered. That pairing therefore reads Allowed here and is refused at runtime. Budget and guardrails are outside the four as well, but neither is a pairing the picker can produce. The docs page states the limitation next to the picker.

✕ Simulations leave no trace. See the audit decision.

✕ The picker cannot answer for a usergroup, a service account, or a frontend user. Only a backend user resolves to the identity the tool gate takes.

Revisit when 

An axis becomes actor-scoped that is not today — a per-user model catalogue, or a configuration-access check folded into the verdict — or a requirement appears for a record of who simulated what. The first widens the scope column; the second is a new log, not a change to the governance stream.

ADR-158: The Editor Action Center adds a catalogue, not a runtime 

Status

Accepted

Date

2026-08-11

Amended

2026-08-11 by ADR-162 (its rejection of a bulk surface)

Authors

Netresearch DTT GmbH

Context 

ADR-152 gave every writing tool a human-facing declaration: a translatable name, a sentence written for a person rather than for a model, an icon, and the record types the action addresses. It also said, explicitly, that each thing it did not build belongs with the consumer that would read it.

This is that consumer, and until now it did not exist. An editor could not find an editor action at all. The five writers are reachable only when a model chooses to call one inside a run, and the only surface that renders the declarations is the admin Tools module — a management list, not a place to do work. nrllm_aitasks (ADR-131) is the one module a non-admin can reach, and it offers prepared tasks and the approvals inbox. There is no catalogue and no per-record entry point.

Everything that makes such a surface safe already exists and is load-bearing: a declared write implies approval (ADR-134), the loop suspends and captures a preview in the run's actor context (ADR-136), the inbox re-authorises that preview per viewer, the composite tool gate answers who may call what (ADR-094), and the write fence refuses a write on a segment with no persisted run or no lease (ADR-141).

Decision 

The Editor Action Center adds a catalogue and one entry point. It adds no execution, no approval path and no authorisation rule.

Three parts, and nothing else.

A catalogue driven by the declarations and the real gate. EditorActionCatalogue reads ToolAvailabilityServiceInterface::editorActions(), narrows by the declaration's recordTypes when a record is carried, and then asks ToolCallPolicyInterface::decide() for every remaining tool. That gate is five checks evaluated as one AND — registered, globally enabled, permitted for this user, inside the configuration's allowed tool groups, inside the trust zone's data-class ceiling. The catalogue re-implements none of them. A hand-rolled "is it enabled" here would be the fourth copy of a five-part rule and would be the copy that ages.

The gate answers against a configuration, so an install with no default LLM configuration offers nothing. Fail-closed and honest: there would be nothing to run the action on.

The configuration itself is a permission, and the tool gate does not check it. ToolCallPolicyInterface::decide() answers "which tools on THIS configuration"; it never answers "whose configuration is it". The beGroups restriction on a configuration means only those backend groups may use it (ADR-070), and no later step re-checks it — the run request carries the configuration straight into the runtime. So the catalogue asks that question too, once, through LlmConfigurationServiceInterface::hasAccess(): the existing ambient-user form of the rule, not a fourth copy. An editor outside the default configuration's groups is therefore offered nothing, and a POST naming an action directly builds no run.

One entry point that carries the record. EditorActionItemProvider adds a single context-menu item on a record, linking to the catalogue for that record's table and uid. It appears only where the catalogue is non-empty for that table and that user, so an editor who may run nothing sees no item rather than an item leading to an empty page. The item is a link: nothing is started from the menu, and nothing about the record is read. The declarations' recordTypes are matched before anything else, and that match reads the tool registry rather than the database — so a right-click on a table no editor action addresses costs no query at all.

Starting an action is an ordinary agent run. EditorActionCatalogue::runRequestFor() builds a plain AgentRunRequest — the default configuration, one user message naming the tool and the record, the caller's AiActorContext, and an allowedToolNames of exactly one — and the controller hands it to AgentRuntimeInterface::run(). The declared write then suspends AWAITING_APPROVAL before it touches anything, and the editor is redirected to the inbox that already renders the card and its preview. No bulk, no second executor, no special runtime: the expected outcome of pressing the button is a pause, not a write.

Consequences 

The same seam answers both questions. groupsFor() decides what is rendered and runRequestFor() decides what may start, and the second one re-asks the first one's question rather than trusting the POST. A request naming a tool the catalogue never offered produces no run. Split across two services, that second check is the one a future entry point forgets.

The catalogue never reads a record. It is handed a table and a uid and passes them on; it does not resolve a title, an existence or a permission. Resolving the record would mean authorising the read, and an unauthorised read would turn a catalogue into a probe for which uids exist. The record IS resolved and authorised, twice and later: by ToolPreviewInterface::previewCall() when the run suspends and by mayViewerReadPreview() when the card renders (ADR-136). The consequence an editor sees is that the page says pages #42 rather than the page's title.

The grant is ``tasks_use``, not a new one. The module's existing execution grant already means "this account may have the extension run a model on its behalf", and it is checked per action on top of the module switch (ADR-130, ADR-131). A second grant for a narrower capability inside the same module would be a switch with no distinct decision behind it — and it would be the weaker of the two protections anyway, because whether a writing tool may run at all is already an explicit admin act: every writer ships isEnabledByDefault() === false, and a configuration that restricts tool groups must list editing. ADR-152 deferred a per-action grant to its consumer; the consumer's answer is that the axis it would add is already covered twice.

The item checks the module, not only the grant. nrllm_aitasks is registered access: 'user', so the be_groups tick and tasks_use are independent axes (ADR-130, ADR-131). The item is a link into that module, so canHandle() asks ModuleProvider::accessGranted() as well — through the provider rather than a copy of the user gate, so changing access in Configuration/Backend/Modules.php changes this answer with it. Without it a user holding the grant but not the module would be offered an item leading to a 403, which is the outcome the grant check exists to avoid.

The offer carries the subject and nothing else. The prompt names one table and one uid, and allowedToolNames holds exactly the offered tool — there is no lookup tool beside it. So a declared recordTypes entry must be a table one of the tool's own required arguments can be filled from (ADR-152 states the rule; a unit test enforces it). An argument that is neither the subject nor derivable from it — move_content_element's target_page — can only come from the editor's free-text note, so that action's human description asks for it. The safety net is unchanged either way: a guessed destination arrives as an approval card showing the page the preview resolved, not as a write.

Files have no context-menu entry. In the file list the context menu's identifier is a FAL combined identifier (1:/path/file.jpg), not a uid, while set_file_alternative_text declares sys_file and takes a uid. Casting one to the other yields a plausible wrong number, so the provider handles integer identifiers only. The action stays visible in the catalogue and is startable once a resolution step exists.

Without a record the catalogue is read-only. An action needs a subject, and this module deliberately ships no record picker: a picker is a read boundary (ADR-130 withheld the table task input type for exactly that reason), and building one here would put a second, weaker boundary beside the one the tools enforce themselves.

Alternatives considered 

A module of its own. Rejected: same audience, same grant, same inbox as nrllm_aitasks, and ADR-119 already calls the admin tree's entry count a dumping ground. A second editor-facing module would also need its own be_groups tick to be reachable at all.

A bulk surface — one action over many records. Rejected here, and ADR-152 already said why: the approval unit is a turn, and ADR-133 refuses a per-call verdict. "Approve 200 writes" is not a decision a card can carry.

Amended by ADR-162. That objection is about ONE run holding many writes; it says nothing about many runs. N records planned as N ordinary runs are N turns, N digests, N verdicts and N fence stamps, so the approval unit is untouched. ADR-162 adds the multi-record entry point on that basis and still builds no bulk runtime.

Filtering the catalogue by hand from enabledNames(). Rejected: that is one of the gate's five checks. It would show an editor actions their configuration forbids and their trust zone refuses, and the refusal would arrive as a failed run instead of an absent button.

Resolving the record title for the catalogue header. Rejected for now: see the consequence above. It is a read that must be authorised, and the authorising code lives in the tools, which cannot be asked without arguments.

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 progressmet, that is what this change set is;
  • a concrete consumer benefits from installing it separatelynot met, no consumer has asked;
  • the contract with core has been stable across several releasesunproven, 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 under Service/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.

ADR-160: One adapter contract, and honest capability provenance 

Status

Accepted

Date

2026-08-11

Authors

Netresearch DTT GmbH

Context 

Two separate honesty gaps, both about a claim nobody could check.

Seven adapters, seven private definitions of "works" 

Classes/Provider/ holds seven adapters — OpenAI, Claude, Gemini, Groq, Mistral, Ollama, OpenRouter. Each had its own unit test and its own opinion of what needed testing. Nothing compared them.

The result was not that adapters were untested. It was that the differences between them were invisible. Reading the suite could not tell you whether "OpenRouter has no test for retry behaviour" meant the adapter does not retry or that nobody wrote the test. Three examples the contract found on its first run:

  • OllamaProvider implements ToolCapableInterface and its supportsTools() returns true, but tools was missing from its $supportedFeatures. The service layer's instanceof gate let a tool call through while LlmServiceManager::supportsFeature('tools', 'ollama') denied the same capability to whoever asked.
  • OpenRouterProvider sends through a private request path (it needs the HTTP-Referer / X-Title attribution headers and a 402 = out-of-credits mapping). That path had no try/catch around sendRequest(), so a connection refusal or a cURL timeout escaped the adapter as a raw PSR-18 exception and reached the caller as an unhandled 500 — the same class of bug the unparseable-body branch right below it already fixed.
  • The same private path never called validateConfiguration(). An OpenRouter with no vault key fell through getHttpClient()'s api-key-less branch and sent a keyless request, so a local misconfiguration reached the operator as the provider's 401 — the provider's name on our mistake, after an outbound call that never had a chance. streamChatCompletion() validated; chatCompletion() did not.

None is exotic. All three survived because no artefact stated what an adapter must answer to.

A capability with no provenance 

tx_nrllm_model.capabilities is a comma-separated token list. An operator's manual tick in the record editor and an answer from the provider's own model endpoint produced byte-identical rows. Afterwards nothing could tell them apart — not the model module, not routing, not the operator.

That matters most exactly where it is least visible. When a provider's model endpoint is unreachable, ModelDiscovery substitutes the static catalog bundled with the extension (DiscoveryResult::fallback()). A model created from that catalog looked, in the database, exactly like a model the provider had confirmed.

Decision 

1. One abstract contract case every adapter extends 

Tests/Unit/Provider/Contract/AbstractAdapterContractTestCase fixes seven things for all seven adapters: the identifier, the capability declaration, error normalisation per exception type, refusing to send without a credential, timeout behaviour, usage reporting, and — where declared — the shape of a tool call and of a structured-output request. Every adapter has a concrete subclass; the four OpenAI-dialect adapters share their wire fixtures through one intermediate case.

Three rules keep it a contract rather than a lowest common denominator:

A capability an adapter does not have is skipped by name. The document, unsupported-schema, credential and retry contracts call markTestSkipped() with the reason. A reader of the run sees nine named skips and can tell "cannot" from "not tested" — which is the entire point. The tool contracts carry the same guard and never fire it: all seven bundled adapters implement ToolCapableInterface. The guard stays for the eighth.

A deliberate deviation is declared, not tolerated. Three hooks — expectedServerErrorException(), retriesTransportFailures() and requiresApiKey() — carry the differences that are real: the first two because OpenRouterProvider does not send through AbstractProvider::sendRequest(), the third because a local Ollama authenticates nothing. Overriding one is a statement in the subclass, with the reason in its docblock. That is where the deviation is now written down; before, it was written down nowhere.

No live calls. Every adapter is driven through an injected PSR-18 double.

The suite lives under Tests/Unit deliberately, not under Tests/Integration. The integration testsuite is in Build/phpunit.xml but no CI job runs it: ci.yml runs ci:test:php:unit, …:functional and …:fuzzy, and integration appears only in the local composer ci aggregate. A conformance suite that no gate executes is a decoration.

Streaming is covered by the declaration contract only. An SSE fixture is dialect-specific enough that a shared one would assert the fixture rather than the adapter, and each adapter's own test already carries one.

The repair round-trip stays where it is. ADR-126's single repair attempt, the nested-keyword limits and the rejection of a schema outside the subset are CompletionService and JsonSchemaValidator behaviour, not adapter behaviour, and both already have tests for them. What the adapter owns is the provider-native request shape, the degradation when the provider cannot enforce the schema, and passing a malformed answer back untouched so the layer that can repair it sees the real body. Those three are in the contract.

2. Capability provenance, with the catalog kept separate 

Three columns on tx_nrllm_model: capabilities_discovered (what the last discovery reported), capabilities_confirmed_at (when, 0 = never) and capabilities_source (a CapabilitySource value).

Per-capability provenance is derived, not stored per capability: Model::getCapabilityProvenance() compares the declared set against the discovered set. A capability the provider named carries that run's source and date; one only the operator ticked carries CapabilitySource::Operator and no date, because there is no confirmation to date. This gives the right answer for free on every record written before provenance existed — nothing confirmed it, and it now says so.

CapabilitySource::Catalog is deliberately distinct from Discovery. Folding the substituted static catalog into "confirmed by the provider" would manufacture exactly the confidence this record exists to remove.

The source follows the capability tokens, not the model list. These come apart, and reading only ModelDiscovery::wasLastDiscoveryFromFallback() would have been the same conflation one level down. OpenAI's /v1/models returns id/object/created/owned_by, Anthropic's returns no capabilities either, and Groq's listing has no capability field: their discoverers read the tokens out of the bundled catalog on the live path exactly as on the fallback path. Gemini's curated table wins over the listing for every model it names. So DiscoveredModel carries capabilitiesFromApi, set by the discoverer and true only where the tokens were derived from the response payload — Mistral's capabilities object, OpenRouter's supported_parameters and input_modalities, Ollama's /api/show array, and Gemini's supportedGenerationMethods for a model the table does not know. CapabilityVerifier records Discovery only when a live list and payload-derived tokens both hold; everything else is Catalog. The practical consequence is that confirming an OpenAI or Anthropic model against a reachable API yields Catalog, which is the true answer — nothing about those tokens was confirmed.

3. The consumer is the model backend module 

The capability column of Backend/Model/List.html renders each capability with its provenance: a plain badge when a live provider answer confirmed it, a warning badge with a question mark and a tooltip naming the source otherwise, plus a "last confirmed" line per row. A "Confirm capabilities" row action (ModelController::verifyCapabilitiesAction) runs discovery for the model's provider and records the answer, so "last confirmed" ages honestly instead of freezing at creation time. The same three fields appear read-only on the Capabilities tab of the record editor.

Why the routing readout on the Governance tab waited. It is not the readout the brief assumed. Backend/Governance.html renders governance profile deviationsGovernanceProfileEvaluator::deviations() over policy rows. There is no per-model capability readout there to annotate, so wiring provenance into it means first building that readout. That is a larger change than this one and belongs with whoever builds it.

Routing does not read provenance, on purpose. Making eligibility depend on it would silently drop every model whose capabilities an operator declared by hand — a behaviour change dressed as a data change. Provenance is informational until someone decides, explicitly, that it should gate.

4. The setup wizard is not a provenance writer 

CapabilityVerifier is the only writer. The wizard deliberately is not one: by the time SetupWizardController::createModels() persists the selected models, the discovery it displayed happened in an earlier request and it can no longer tell a live answer from the substituted catalog. Stamping "confirmed by discovery" there would be the same conflation in a different place. A wizard-created model therefore starts as unconfirmed — which is true, and which is what makes the Confirm action worth clicking.

Consequences 

✓ Adding a provider means writing a contract subclass, and the abstract case enumerates what the adapter has to answer to. A capability it lacks is a named skip, not an omission.

✓ Three real defects are closed: Ollama's contradictory tool declaration, OpenRouter's raw transport exception, and OpenRouter's keyless request on an unconfigured adapter. All three were found by the contract on its first run rather than in production.

✓ An operator can see which capabilities the provider actually confirmed, and when.

supportsFeature('tools', 'ollama') now returns true where it returned false. That is the correct answer — the adapter has always been able to make tool calls — but it is a behaviour change for anything that branched on the wrong one.

✕ Two contract holes exist by declaration, both from OpenRouter's private request path. It maps a 5xx other than 503 to ProviderResponseException rather than ProviderConnectionException — 503 has its own arm in handleOpenRouterError() and keeps the shared class — and it does not retry transport failures at all. Neither is fixed here, because unifying that path means changing its 401/402/429/500 messages, which existing tests pin.

The 5xx mapping does not cost a fallback hop. FailureClassifier (ADR-095) reads the carried HTTP status, not the exception class: a ProviderResponseException with a 5xx code classifies as FailureClass::SERVER_ERROR, which answers isRetryable() and tripsCircuit() exactly as CONNECTION does, so FallbackMiddleware hops either way. What it costs is the class a caller catches and the wording: a handler with a catch (ProviderResponseException) arm ahead of a generic one — ProviderController::testConnectionAction() is the in-tree case — takes that arm for an OpenRouter 5xx and the generic ProviderException arm for every other adapter's, and the message reads OpenRouter API error (502): … where the shared path says Server returned status 502.

✕ Provenance is per confirmation run, not per capability write. An operator who edits the capability list right after a verification gets the new capability attributed to themselves — correct — but the confirmation date of the untouched ones does not move, which is also correct and may read as stale.

Explicitly out of scope 

No new provider adapter. ADR-147 keeps AWS Bedrock and Google Vertex AI a deliberate gap with two named triggers — symfony/ai-platform reaching 1.0, or a named customer requirement. Neither has fired, and a conformance suite is not one of them.

ADR-161: One conformance suite for every MCP connection we support 

Status

Accepted

Date

2026-08-11

Context 

ADR-116 put an MCP client in this extension and drew its edges: HTTP only, no stdio, no SSE, no resources, prompts or sampling. ADR-154 gave an operator a way to see whether a configured server is alive. Between them the client is well covered by class — the transport has its own tests, the client has its own tests, the schema normaliser has its own tests — and covered by nothing that asks the question an operator actually has:

Is what this installation supports as an MCP client fully policy-, audit- and health-integrated?

That is a different question from "does the client work", and it is the only one worth a conformance suite. A per-class suite answers "does listTools() paginate"; it cannot answer "does a remote tool reach the trust-zone gate as a remote tool", and it cannot notice that two of the checks below were quietly false.

The scope here is deliberately narrow and is not "nr_llm can do everything MCP can". It is: everything nr_llm supports as an MCP client is fully policy-, audit- and health-integrated.

Decision 

  1. One suite, one case per connection. AbstractMcpConformanceTestCase holds every check. A concrete case supplies an McpConnectionProfile and nothing else, and the whole list has to pass for it. Two profiles ship: a stateless HTTP server that issues no session, and one that issues a session id — with different data classes and opposite approval declarations, so the classification checks are not silently asserting one constant twice.

    A profile carries only what legitimately differs between two servers this client speaks to: what the operator configured, and what the server does about session state. Every response, failure and bound is scripted identically for all profiles, because a check that varied with the connection would not be a conformance check. Adding a transport later means adding a profile and a three-line subclass.

    No live network. Everything runs through a faked PSR-18 client, so the real JSON-RPC encoding, the real status handling and the real handshake ordering are exercised rather than a description of them. There is deliberately no authenticated profile: authentication happens in McpHttpTransport::clientFor() , which that seam bypasses by design, so an authenticated profile would send identical bytes and assert nothing about the credential. The one check that needs that method reaches it directly.

  2. The checklist, and what each item is. Every item is covered here, covered elsewhere and named, or out of scope and named. Nothing is listed as covered because a test exists with a matching word in its name.

    Check Where it stands
    connect Covered. The handshake, the readiness notification the protocol requires, and the server's session decision carried through the whole operation — including the absence of a session header when the server issued none.
    capability discovery Covered, in both directions. This client declares none, because declaring one invites the server to use it; what the server declared about itself is read and reported, protocol revision included.
    tool discovery Covered: the paginated walk, resumed from the cursor, and the page ceiling that ends a cursor which never resolves.
    JSON schema normalization Covered, on a schema that came off the connection rather than a literal: the five retained keys survive, the annotations around them do not.
    tool execution Covered: the remote name goes on the wire, the local name is what the gate and the model see, and the result comes back as a ToolResult — an error one when the server set the protocol's own isError, which is how a working server reports a tool that failed.
    timeouts Covered on both halves: the transport puts a finite timeout on the client it builds (asserted against that client, not against the constant), and a connection that never answers becomes a failed tool result rather than an escaping exception.
    cancellation A gap. Pinned by its absence, not by a behavioural check. See decision 3.
    server failure Covered across eight shapes — 5xx, 3xx, 401, a JSON-RPC error object, a maintenance page, an event stream, a body with neither result nor error, an empty body — all ending as one failed tool result naming the server, with no health contact recorded.
    invalid schema Covered at both ends of the same rule: a schema that cannot be represented is rejected whole rather than repaired, and a stored schema that no longer decodes to a JSON object has no schema to offer. That such a row yields no registered tool while the row itself survives is McpToolProvider 's decision, which a pack built around one connection cannot reach; it is covered over real rows in McpImportServiceTest.
    oversized response Covered: a 3 MiB body is refused at the 2 MiB read cap, and what the far side sent does not become the message we repeat.
    audit Covered, and it needed a decision and a fix. See decision 4.
    data classification Covered here as the tool's own declaration: the class and the approval requirement travel on the tool, so the gate reads them without a second lookup, and nothing the server sent produces either. That the declaration is taken from the operator's server row and not from the annotations the server wrote about itself is again McpToolProvider 's decision, and is asserted over real rows in McpImportServiceTest: a tool whose annotations claim publicContent on a server the operator declared internalConfiguration resolves as internal configuration.
    trust-zone enforcement The gate's own branch — a RemoteToolInterface tool is refused above the ceiling even in observe mode — is covered by ToolCallPolicyTest (ADR-154) and is not repeated here. What this suite adds is the other side of that seam: an MCP tool presents itself to the gate as the thing the branch keys on, with the classification the gate compares and the admin requirement it applies first.
  3. Cancellation is a gap, and the suite says so. AgentRuntime::cancel() flips persisted state and the loop stops at the next step boundary. The transport has no abort path, so a cancellation raised while a request is in flight changes nothing about that request: the run waits the leg out, bounded only by the 15-second timeout (ADR-154 already listed this under what it did not decide).

    The suite does not fake a passing cancellation test, and it does not pretend to a behavioural one either. There is no behaviour to assert: a cancellation cannot reach an in-flight call because nothing on this path can be told about one. So what the suite asserts is that absence, structurally — McpClient and McpHttpTransport take no cancellation collaborator, no cancellation argument, and expose no method to abort what is open.

    That check FAILS the day such a seam appears, which is the only property worth having here. The rejected alternative was a fake client running a closure mid-request: it looks behavioural, but the closure has nothing cancellable to raise, so the check would have stayed green through the very change it claimed to guard. A check whose name promises more than it asserts is worse than the prose it replaces.

    It is not built here because it is not one change. Guzzle's synchronous client has no cancellation, so it means an async request plus a poll loop, a decision about what a half-sent tools/call means for a non-idempotent remote write, and a bound on how long cancellation itself may take. That is its own record.

  4. The run's audit IS the audit; the MCP client keeps none of its own.

    The premise worth checking was that a ToolLoopService run without an AgentRunPersister executes MCP tools and writes nothing to tx_nrllm_agentrun_event. That is true of the bare wiring — and it is not reachable through the runtime, because of a guarantee that already exists:

    • McpTool::getEffect() is NON_IDEMPOTENT_WRITE for every imported tool, a pure search included (ADR-116).
    • ADR-141's fence refuses a write-effect tool in a segment that holds no persisted run and no lease, before the call happens.
    • ADR-111's audit is fail-closed for a write step: a tool step that cannot be stored fails the run.

    So "an MCP tool executed and nothing was recorded" is not a state the runtime can reach. That is asserted, not reasoned about: McpRunAuditTest drives a real run through the real persister and finds the TOOL event, and drives a second one whose run row cannot be written and finds that the server received nothing at all.

    Two things therefore stay as they are, deliberately:

    • The MCP client writes no audit rows of its own. A second audit stream beside the run's would answer the same question in two places and disagree the first time one of them failed. What the client does write is liveness (ADR-154), which is a different fact with a different reader.
    • ``tx_nrllm_governance_event`` stays denial-only. It records what policy refused. An executed call is not a policy event, and turning that table into a second call log would make "which direction leaks" — the question it exists for — a query over a mixture.

    One thing did change, on both of the two paths a remote call fails on. McpTool::execute() returned a transport failure as ordinary TEXT, so a server that was down was persisted as a successful tool step whose content happened to read like an error. The same held — and mattered more — for the protocol's own isError, which is how a working server reports that the tool failed: McpClient::callTool() folded it into a prefixed sentence, and the flag was lost at the return type. It now returns an McpCallOutcome carrying the flag, and both paths produce an error result. Nothing about the run's control flow changes — the loop still carries on, the model is still told plainly what failed — but "how often does this server fail" now has an answer that is not a string search, for the common failure as well as the loud one.

  5. A dropped content block is named, not swallowed. MCP lets a server answer with typed content blocks. This client reads text and cannot carry an image or an embedded resource, which is correct — but it said nothing, so a model handed a partial answer could not tell, and a model handed only non-text blocks was told the tool "returned no textual content" about a call that in fact returned an image.

    The answer now begins with a note counting what was dropped and naming the types. It leads rather than trails because a tool result is cut to a byte bound before the model sees it ( ToolResultBounder , 50 000 bytes), and a trailing note is removed by exactly the cut that makes the answer partial — losing the sentence on the long answers where being told is worth most. The note is ours, not the server's, and contains none of its bytes: a dropped block is named by matching its type against the protocol's own four non-text types (image, audio, resource, resource_link) and anything else is other. Sanitising and clipping the remote string was the first attempt; it bounds the note's length but not its authorship, and a server that invents a type per block would have been writing words into a sentence the model reads as ours. The count stays exact, so such a server moves the number and nothing else. Rendering the blocks instead is the other decision, and it is not this one: it needs a data class for binary content the operator never declared, and a place to put bytes that a tool result has no channel for.

Consequences 

  • Two production changes ride with the suite, both listed above: a failed remote call is an error result, whether the wire failed or the tool did (decision 4), and a dropped content block is reported (decision 5). The first changes McpClient::callTool() 's return type from string to McpCallOutcome . That is a signature change, but not a break of the frozen surface: neither the class nor the method appears in Tests/Unit/Api/api-surface.txt, and McpTool is its only production caller.
  • The suite is 34 checks per connection and runs in well under a second: it drives a faked PSR-18 client, so a run costs no network and no database. It is unit-level, and the checks that are claims about rows live in the functional suite instead and are named where the table lists them: the audit half in McpRunAuditTest, and the two the catalogue resolves — classification and an undecodable stored schema — in McpImportServiceTest. A per-connection pack cannot assert them: it holds one connection and builds its tool directly, so it can pin what a tool declares but not what turned a row into that declaration.
  • AbstractMcpConformanceTestCase is named …TestCase, not …Test, because Build/phpunit.xml globs *Test.php and PHPUnit turns an abstract match into a runner warning — which failOnWarning turns into a red suite.
  • A new transport does not get a new suite. It gets a profile and a subclass, and the existing list decides whether it is supported.
  • The adapter conformance suite (ADR-160, a sibling change at the time of writing) is the shape this follows: an abstract case, per-scenario fixtures, a faked client at the bottom. The two suites share no code, because a provider adapter and an MCP connection have no common contract to abstract — what they share is the discipline.

Deliberately not decided here 

  • Cancelling an in-flight call. Decision 3 says why. The check that asserts the seam does not exist is the entry price for building one: it goes red on the first commit that adds it, and this record has to be revisited.
  • Retry, backoff and circuit breaking. Unchanged from ADR-154.
  • Rendering non-text content blocks. Decision 5.
  • Non-HTTP transports, resources, prompts and sampling. The edges ADR-116 drew; this record holds the client to what is inside them rather than widening them.
  • A per-server call log. Liveness answers "is it up" and the run's event stream answers "what did it do". A third table between them has no reader.

ADR-162: Bulk editor actions are N ordinary runs, not a bulk runtime 

Status

Accepted

Date

2026-08-11

Amends

ADR-158 (its rejection of a bulk surface)

Authors

Netresearch DTT GmbH

Context 

ADR-158 gave an editor one action on one record. The next thing an editor asks for is the same action on the twenty pages they just imported, and both earlier records refused it.

ADR-152 declined a bulkCapability flag on the declaration. ADR-146 put "what did the approver agree to" on its Revisit when list. ADR-158 rejected a bulk surface outright. All three cite the same reason, and it is a good one: the approval unit is a turn. One suspension produces one digest and one verdict for every pending call in it, and ADR-133 explicitly refuses a per-call verdict — an approver either takes the turn or refuses it. "Approve 200 writes" is not a decision a card can carry. ADR-141's write fence stamps ONE pending effect per run row, so a runtime that folded many records into one run would also have to invent a second stamping rule.

Decision 

A bulk editor action is N ordinary agent runs. There is no bulk runtime, no batch approval, no shared digest and no queue of its own.

That single sentence is also the answer to the objection all three earlier records raised. The objection is about one run holding many writes. It says nothing about many runs:

  • N runs are N turns — one suspension each.
  • N turns are N digests and N verdictsADR-133 is satisfied without an exception, because no approver is ever asked to decide more than one turn at a time.
  • N runs are N run rows, so ADR-141's one-pending-effect fence stamps once per row, unchanged.
  • Budget, audit, routing, tool policy and idempotency see N indistinguishable single-record runs, because that is what they are.

Nothing about the approval unit changes, and that is the whole reason this is buildable now while a bulkCapability flag still is not.

Per record, the catalogue is asked again. EditorActionBatchPlanner calls EditorActionCatalogueInterface::runRequestFor() once for each record and collects the answers. A record the viewer may not act on comes back as null and is recorded as skipped with a reason an editor reads, on the confirmation page and again in the flash messages after the batch. It is never dropped quietly, and the batch is never authorised once and applied to the rest.

The declaration used for the page heading IS resolved once for the batch — a translatable name has to come from somewhere — and that is presentation only. Authorisation stays at one runRequestFor() per record.

Honest about what that check does today. runRequestFor() authorises on tool, table, configuration and viewer, and validates the record number only for being a positive integer. Asking it twenty times therefore returns twenty identical answers right now. It is still asked per record, because the catalogue is the seam that owns the question: a per-record axis added there — a record-level permission, a workspace state, a lock — must reach this surface without anyone remembering to come back and change a loop.

A bound of 20 records. Two real constraints set the ceiling. The batch runs its N runs synchronously inside one backend request, using the same AgentRuntimeInterface::run() the single-record path uses, and each run costs at least one provider round trip; a batch that outlives max_execution_time is truncated mid-loop, which is the one failure mode that leaves an operator unable to say what started. And each suspended run is its own inbox card with its own verdict, so the number also bounds how many decisions one press hands an approver. Twenty is a judgement inside those two bounds, not a measurement. Raising it means moving the loop onto AgentRuntimeInterface::enqueue(), which is a separate decision with its own failure modes; this record does not take it.

The cost estimate, and how wrong it is 

The confirmation page shows requests, tokens and a price range before anything starts. Every number is derived from something the plan already holds. None is a typical-case guess, because a made-up range is worse than no range.

Number Where it comes from
Runs The count of records the catalogue offered — the plan's own entries.
Provider requests Runs × 2. Two is derived, not assumed: the first send decides the tool call and the declared write suspends (ADR-134); the send after approval turns the tool result into the answer. It is a FLOOR, and the page says "at least".
Input tokens TranscriptEstimator (ADR-107) run over the ACTUAL messages of the first AgentRunRequest the plan built, plus the JSON schema of the one tool the run may call, taken from the registry with the run's own allow-list. Every request in a batch differs only in a record number, so the first stands for all of them.
Output ceiling The configuration's own maxTokens. What the provider is asked to respect, not a guess at answer length. A maxTokens of 0 means unbounded on LlmConfiguration — no max_tokens goes on the wire at all — so it is reported as "no ceiling", never as a ceiling of zero.
Price range Model::estimateCost() on the model record's stored per-million prices. Low assumes no output at all; high assumes every request returns the full ceiling. Shown only when both rates are stored and a ceiling exists.

How wrong it can be, and the errors do not cancel:

  • Under, materially. The loop's assemble() step prepends the configuration's system prompt and its skills before the first send. Those are not in the request the plan holds, so they are not counted. A long system prompt can be larger than the whole measured prompt.
  • Under. The second send carries the first send's transcript plus the assistant tool call plus the tool result. The estimate charges it the initial transcript, which is strictly smaller.
  • Over. TranscriptEstimator errs high by construction (ADR-107): a chars/3.5 prose divisor over UTF-8 bytes, plus per-message overhead.
  • Over, by a lot, on the high price — whenever a range is shown at all. maxTokens for every request is a ceiling an editorial change never reaches. The upper bound is a ceiling, not a forecast.
  • Stale money. Prices are what an administrator typed on the model record. Nothing in this extension refreshes them.
  • No range at all in three cases, and the rule behind all three is the same: a bound of 0.00 reads as "free" when it means "unknown", so no bound is printed unless it can be computed. The configuration names no model. The model is priced on one side onlyModel::hasPricing() is true when either rate is set and estimateCost() charges the missing one as zero, which would price a whole output ceiling at nothing, so both rates are required here. Or the configuration sets no output ceiling, which leaves the upper end unbounded and therefore unquotable.

The estimator's learned calibration factor is deliberately NOT applied: it belongs to a live context window, and borrowing it would make the same batch quote different numbers on different days for no reason an editor can see.

Historical per-action usage was considered as a source and rejected on the facts: tx_nrllm_service_usage aggregates per day, service type and provider. It has no per-tool and no per-editor-action dimension, so it cannot answer "what did this action cost last time".

Consequences 

A half-done batch is real, and it is visible. The budget gate (BudgetServiceInterface) is hit once per run, which is correct — N runs are N spends. An operator who starts twenty runs on an account with room for eleven gets eleven. This record does not pretend otherwise:

  • The loop stops at the first run the budget refuses. Continuing would produce nine more identical denials and nine more empty run rows.
  • A budget-denied run settles COMPLETED, not FAILED, and the stop is detected on that shape. ToolLoopService catches the denial itself and returns a truncated result carrying AgentRunTerminationReason::BUDGET_EXHAUSTED (ADR-092); the executor has no arm for that, so the run is settled as an ordinary completion with no error on the result. The batch therefore reads the loop result's termination reason. It also accepts a BudgetExceededException on the result's error, because the loop's no-tools branch sends outside that catch and the exception does propagate there — but a guard that watched only the error would be dead code on the path an editor action actually takes.
  • The record the budget refused is not among the never-started: it was run. The message says the batch stopped, and names the records the stop kept from starting only when there are any — a denial on the last record still reports the stop.
  • Every other terminal outcome is per record and does not stop the batch: a model declining one page is not a reason to abandon the other nineteen. They are counted by kind, with the same partition the single-record path uses: a run that failed, one a guardrail stopped, one that was cancelled and one that simply proposed nothing are four different things to an editor, and a batch in which everything failed must not read like a batch in which nothing needed changing. SUSPEND_FAILED counts with the failures — an approval was required and could not be stored, so nothing can resume it.

The mitigation that makes a half-done batch survivable is the one the design already relies on: because a declared write suspends before it touches anything (ADR-134), a batch that dies at run twelve has produced eleven proposals, not eleven writes. Nothing is half-changed; some approvals exist and some do not.

The plan is rebuilt on the POST. The confirmation page's plan is not carried into the request that starts the batch — a plan carried across a request is a permission carried across a request. startBatch re-plans from the same raw inputs, so a POST naming records the GET never offered starts nothing.

Duplicates and junk are reported, not absorbed. A record number named twice is planned once and the second mention is listed as skipped. Entries that are not record numbers at all are counted and reported. Records beyond the cap are listed as skipped rather than truncated away.

A second ceiling bounds the input, not the runs. The cap of 20 bounds how many runs START. It does not bound how much is parsed: every number past it still becomes an entry, a table row and a record number in a session-stored flash message, so a pasted megabyte would build a page nobody can read. The raw list is therefore cut at MAX_INPUTS — five times the cap, which leaves room for duplicates, junk and a generous over-paste — and the cut is reported on the page and in the messages. It falls on a separator, never inside a number: a truncation that shortened 1234 to 12 would name a different record. The count of what was dropped is deliberately NOT given, because counting the tail means parsing the tail, which is the work the ceiling exists to refuse.

The entry point takes record numbers, and does not pick them. ADR-158 withheld a record picker because a picker is a read boundary (ADR-130), and that reasoning is unchanged: this surface enumerates nothing and reads no record. It is handed a table and a list of numbers — seeded from the record the context menu carried — and passes them on. The consequence an editor sees is a text field of numbers rather than a list of titles.

Alternatives considered 

One run holding N tool calls. Rejected — this is the shape all three earlier records refused, and refusing it is the premise of this one. It would need a per-call verdict (ADR-133 says no), a digest format for many writes, and a second write-fence rule for many pending effects on one row (ADR-141).

A queue of its own. Rejected: AgentRuntimeInterface already has enqueue(), and a second one would be a second lifecycle. Not used either — its transport is the operator's choice and defaults to synchronous, so it does not remove the timeout the cap exists for, while adding an outcome shape this surface would have to explain. The cap is the honest answer until asynchronous execution is a decision someone takes deliberately.

Continuing past a budget denial. Rejected: every later run hits the same exhausted bucket, so the only product is noise — nine more failed rows and nine more messages saying the same thing.

Reading the record list's clipboard for the selection. Not built. Clipboard::elFromTable() answers for the CURRENT pad, and the "normal" pad holds a single element, so what an editor gets from a tick-and-click depends on pad state this record's authors did not verify. Shipping a selection source whose behaviour is assumed would be worse than a field an editor fills in. The entry point takes a plain list of numbers, so a verified clipboard reader can feed it later without changing anything else.

ADR-163: A use-case pack is data plus a small installer 

Status

Accepted

Date

2026-08-11

Authors

Netresearch DTT GmbH

Context 

Setup starts at "Provider". That is the last question an operator can answer and the first one the wizard asks: endpoint, adapter type, API key, then models, then a configuration. Someone who wants help writing teasers has to translate that want into four technical decisions before anything happens, and nothing in the product makes the translation for them.

The pieces a working setup needs already exist separately — configuration presets (ADR-056), tasks, prompt snippets, tool groups, governance profiles (ADR-145). What is missing is a name for a combination of them and a place to ask the earlier question.

Decision 

A pack is DATA. UseCasePack declares a configuration preset, task records, snippet records, a recommended governance posture and the tool groups its tasks benefit from. It has no behaviour. UseCasePackInstaller is the only thing that writes, it writes ordinary records through the existing services, and it is roughly a hundred lines. A pack that could configure would be a second configuration system beside the one it is made of.

The configuration half IS a configuration preset. Not a copy of one — the same ConfigurationPreset , published to the preset registry through UseCasePackPresetProvider . ADR-056 already expresses model REQUIREMENTS rather than a chosen model, already preflights them against the installed models, and already owns import and drift resolution. The visible consequence is intended: a pack's configuration also appears in the Configuration module's pending list, and importing it there installs exactly that configuration.

A pack recommends a governance posture; it cannot apply one. ADR-145 decided that a profile describes and never enforces, and this record does not reopen it. The pack names the posture its content was written for, the plan screen renders it next to a link to the governance readout, and installing changes no governance value. The same reasoning covers tool groups: the pack names them, the Tools module's admin enable stays the only way to switch one on. A pack that enabled its own tools would hand an install button the authority of the tool gate.

Nothing is written before an operator confirms. plan() is read-only and answers what would be created, what is already there, and whether the configuration's requirements can be met at all. The confirm is a POST; the install action refuses a GET, so a bookmark or a prefetch cannot provision.

"Already installed" means a record with that identifier exists. Nothing more. The installer never overwrites and never compares contents: an operator who renamed a pack task or rewrote its prompt owns that record. The single exception is the configuration's snippet-tag selection, described below — an addition, never a replacement. Identifier lookups run through the repositories' backend query settings, which ignore enable fields, so a record the operator DISABLED still counts as installed — otherwise "install again" would quietly resurrect what they switched off.

The configuration is the one hard requirement. When it is neither present nor importable, the install is refused rather than half-applied. Tasks pointing at a configuration that does not exist fall back to the default one and would quietly run under settings the pack never described.

Snippet tags are derived, not declared. A configuration composes the active snippets carrying any of its tags (ADR-031), so the pack takes the union of its snippets' own tags. A separately declared tag list could name a tag no snippet has, and the snippets a pack installs would then be read by nothing.

The tag link is written on every install, and it only adds. It is the one field the installer sets on a record it did not create, and the reason is the bridge above: the pack's configuration can equally be created by importing its preset in the Configuration module, and that import writes no snippet_tags — ADR-056 does not own that field. Writing the link only on the created record would mean that whoever imported the preset first got a pack whose snippets are installed, active, and composed into nothing, with no error anywhere. So the installer adds the pack's tags to whatever the configuration already selects, never removes, and re-adds nothing that is already there. What it would add is listed on the plan screen, and a plan whose records all exist but whose tag link is missing still offers the confirm button — that is the state that repairs it.

A shared tag reaches in both directions, and the plan says so both times. Snippets are selected by tag, not by owner, and the vocabulary is free-form and shared. Outwards: any existing configuration that already selects tone_of_voice composes the pack's house-style snippet the moment it is created. Inwards: adding tone_of_voice to the pack's own configuration composes every active snippet that already carries it, including operator snippets the pack never saw.

The inward direction is the sharper one, because a snippet brings its data class with it. Classification takes the STRICTEST class over the composed snippets, so one CONFIDENTIAL operator snippet raises the whole configuration and an enforcing input-context gate then refuses every send through it — a configuration that worked before the install stops working, with nothing on the screen having predicted it.

The installer prevents neither direction — scoping snippets to one configuration would be a second selection mechanism beside ADR-031 — but the plan screen names both: every configuration the new snippets would reach, and every existing snippet the added tags would pull in, with its data class. The operator confirms this screen and cannot confirm an effect they were not shown.

What a pack does not contain 

Skills. A skill record carries provenance and a trust level from the source it was synced from: a body checksum, a source SHA, an injection scan, a trust level the trust gate reads. A pack has none of those and could only fabricate them. A pack-installed skill would therefore be a first-party-looking record with no source — precisely the shape the skill trust model exists to prevent. Packs point at the Skills module instead.

Five of the six use cases. UseCase names all six the plan asks about — editorial, translation, metadata, media accessibility, agent workflows, developer integration — because the entry step has to offer the question whole. Only Editorial Starter is built. A use case with no pack says so and links to the technical wizard rather than hiding itself, which is a more useful answer than a question with invisible options. The others are not built; nothing here claims otherwise.

Consequences 

✓ An operator can start from what they want to do, see exactly which records would be created, and get a working editorial setup in one confirmed step.

✓ Re-running an install is a no-op for everything already there, including records the operator edited or disabled, and including a snippet tag the configuration already selects.

◐ An install changes the prompts of existing configurations that already select one of the pack's snippet tags. That follows from tag-based selection and is not prevented; it is computed read-only and listed on the plan screen before the operator confirms.

◐ It changes the pack's own configuration the same way, in reverse: existing snippets carrying the added tags are composed into it, and the strictest data class among them becomes the configuration's. Where the input-context gate enforces, that can refuse sends the configuration made before. Also not prevented, also computed read-only, and listed on the plan screen with each snippet's data class.

✓ No second configuration system, no second policy engine, no bypassed gate. The pack's configuration lives in the preset lifecycle, its governance is a recommendation, its tools go through the admin enable, and its tasks are ordinary tasks.

◐ A pack cannot be uninstalled. Nothing marks a record as pack-owned — that is what makes the installed records ordinary — so removing them is deleting records, one by one, like any other. A remove flow would need ownership tracking and would have to decide what to do with an edited record; neither is worth it for four tasks and two snippets.

◐ A changed pack declaration updates nothing outside the configuration. The preset drift flow covers the configuration half; a task whose declared prompt changed after installation stays as installed. Detection is possible the same way the preset does it (a stored checksum) and is deliberately not built: the first question is whether operators want their edited tasks touched at all.

✕ A pack says nothing about providers or API keys. It requires capabilities and lets the preflight report what is missing. Installing a pack on an installation with no models still needs the technical wizard, and every screen links there.

Revisit when 

A second pack needs something the shape does not carry — an agent-workflow pack would want an agent definition, a media pack a speech or image configuration. Both are additive: the pack gains a field and the installer a branch, and none of the decisions above change.

Changelog 

All notable changes to the TYPO3 LLM Extension are documented here.

The format follows Keep a Changelog and the project adheres to Semantic Versioning.

Version 0.20.0 (2026-07-16) 

Closes out the operator-config audit: every backend-visible provider, model and configuration setting now either works or is gone. Three breaking changes; full details in the repository CHANGELOG.md.

  • Breaking: the provider API timeout is now applied as a total-response timeout (previously write-only — requests ran unbounded). Default moves 30 → 120 seconds; the nrLlm_providerApiTimeout120 upgrade wizard migrates rows persisted at the old default; timed-out requests are not retried (#384).
  • Breaking: Max retries counts retries after the initial attempt — 0 sends exactly one request instead of none (#387).
  • Breaking: the never-functional PromptTemplate stack is removed (ADR-069); drop the orphaned tx_nrllm_prompttemplate table via the database analyzer (#399). BudgetServiceInterface::check() gained an optional ?LlmConfiguration parameter (#389).
  • Per-configuration daily limits (requests / tokens / cost) are enforced alongside per-user budgets — most restrictive wins (#389).
  • Model Max output tokens and Dimensions act as call defaults when the caller and configuration leave them unset (#390).
  • Provider Organization ID is sent as OpenAI-Organization; options.customHeaders is applied on all request paths (#388).

Version 0.19.1 (2026-07-15, first shipped with 0.20.0) 

Prepared but never tagged; its fixes first ship in 0.20.0.

  • Criteria-mode configurations work with every *ForConfiguration() call — the model is resolved through ModelSelectionService instead of the (null) direct relation (#372).
  • Embedding cache tags built from dotted preset identifiers are sanitized (#372).

Version 0.19.0 (2026-07-14) 

  • Per-user usage attribution for the specialized speech and image services (ADR-057): transcription, synthesis and image generation record the calling backend user instead of the ambient bucket (#362).
  • Configuration identifiers with dots no longer crash cached calls — cache keys and tags are sanitized (#365); LlmTranslator chat rows carry the per-user attribution (#361); backend model-test button fixed (#363).

Version 0.18.0 (2026-07-14) 

  • Configuration presets module UI (ADR-056 follow-up): the Configurations backend module surfaces pending presets with one-click apply/update.

Version 0.17.0 (2026-07-13) 

  • Configuration presets: consuming extensions declare the LlmConfiguration records they need via the nr_llm.configuration_preset registration (ADR-056).

Version 0.16.1 (2026-07-10) 

Compatibility fixes for strict-mode MySQL/MariaDB, surfaced by the new MariaDB CI leg: the setup wizard's AJAX persist path now enforces the column length limits and the TCA identifier contract (overlong values previously produced a 500 instead of being truncated), generated identifier suffixes are collision-free within a batch, and decimal-backed values such as temperature are rounded to their column scale so every DBMS round-trips identical values.

For the complete, itemised list see the canonical CHANGELOG.md.

Version 0.16.0 (2026-07-10) 

This release adds RAG site-search tools: the new rag tool group lets agent runs answer questions about the website's own public content with cited evidence. site_rag_query returns curated sources (source_id, title, URL, match excerpt) from a priority cascade over whichever search index is installed — EXT:solr (via its HTTP select API), ke_search, indexed_search — with an always-available pages/tt_content database fallback that matches natural-language questions word-wise; site_fetch_source reads a source's full indexed text. Index-level filtering is strictly public-only (what the anonymous visitor could read), and every evidence package names the answering backend. See ADR-049.

For the complete, itemised list see the canonical CHANGELOG.md.

Version 0.15.0 (2026-07-09) 

This release turns the Tool Playground into a glass-box run inspector — every request, response, tool execution and the final answer stream live into an ordered step list with token, cost and timing detail, plus a dry-run mode that shows the exact prompt without calling the model — and grows the built-in tool set to 39 tools in 7 groups: record history ("who changed this?"), URL resolution, TCA/TypoScript validation, error-analysis (last exception, source read/search, URL probe), FAL file tools and system diagnostics. Groups can be toggled centrally, restricted per configuration and selected per run, cascading fail-closed.

Breaking: ToolInterface gained getGroup(): string — every tool implementer must declare its group (third-party tools: use the extension key).

For the complete, itemised list see the canonical CHANGELOG.md.

Version 0.14.1 (2026-07-05) 

A patch release fixing tool calling with parameterless tools. A tool that takes no arguments emitted its (empty) JSON-Schema properties — and its empty replayed call arguments — as a JSON array [] instead of an object {}, which strict providers such as Ollama reject with an HTTP 400. The bounded agent loop and the Tool Playground now work when a parameterless tool (environment, PHP info, or backend user/group introspection) is offered.

The Skills, Tools and Playground admin documentation was also refreshed to match the shipped backend (module section count, the full built-in tool catalogue, two-tier tool authorization, and updated screenshots).

For the complete, itemised list see the canonical CHANGELOG.md.

Version 0.14.0 (2026-07-04) 

This release adds a Skills and Tools system: extensions and editors can ingest SKILL.md files from GitHub (SHA-pinned, admin-reviewed) and attach them to tasks and configurations, and a function-calling tool runtime lets a model run an agent loop over an admin-curated, permission-gated set of tools — with an interactive tool playground in the backend. It also lands a broad security and accessibility hardening pass (SSRF/CSRF fixes, API keys moved out of URLs, RBAC on tool execution, EN/DE translations, WCAG text alternatives), and the CI now actually runs the functional and backend end-to-end suites so they gate merges.

Breaking: custom ToolInterface implementations must now declare a requiresAdmin(): bool method — true for tools that expose system, host, or cross-user data, false for tools that self-enforce the acting user's TYPO3 permissions. Without it the tool fails at runtime (ADR-038).

Together AI, Fireworks AI and Perplexity are now first-class OpenAI-compatible providers, and provider endpoints entered in the wizard or the record editor are canonicalized on save so they no longer break when saved without an API version path.

For the complete, itemised list see the canonical CHANGELOG.md.

Version 0.13.0 (2026-06-26) 

Provider selection is now database-driven end to end. Breaking: the extension-configuration defaultProvider fallback is removed — select a provider per call (the options object's provider field) or mark a Configuration active and default in the backend module; otherwise getProvider(null) throws (ADR-034). The dead plugin.tx_nrllm TypoScript constants/setup were removed and the "no provider specified" error now carries actionable backend-module guidance (#254, #255).

For the complete, itemised list see the canonical CHANGELOG.md.

Version 0.12.0 (2026-06-11) 

Specialized services (image, text-to-speech, transcription) gain full usage and cost tracking, join the model registry with image/text_to_speech/ transcription capabilities, and resolve their model and system prompt from Configuration records (ADR-032, ADR-033). Adds a prompt-snippet library (tx_nrllm_promptsnippet, ADR-031), per-request timeouts on the secure HTTP client, and arbitrary gpt-image sizes. Requires nr-vault ^0.10.0.

For the complete, itemised list see the canonical CHANGELOG.md.

Version 0.11.1 (2026-06-10) 

Security and robustness fixes from the extension-wide code review: the setup wizard dispatches through nr-vault's SSRF-guarded secure HTTP client, the provider adapters surface streaming errors as typed, credential-sanitized exceptions, TTS text splitting is multibyte-safe, and FAL/Whisper configuration parsing is hardened.

For the complete, itemised list see the canonical CHANGELOG.md.

Version 0.11.0 (2026-06-10) 

The backend module's default Configuration is now the single source of truth for generic completion: chat(), complete() and streamChat() resolve the active default database-backed configuration (provider adapter, model and vault-backed credentials) when no provider is pinned, with per-call options overriding the stored defaults. The extension-configuration defaultProvider becomes a fallback for installations without a usable default configuration.

For the complete, itemised list see the canonical CHANGELOG.md.

Added 

  • Default-configuration routing for generic completion. Calls without a pinned provider route through the module-managed default LlmConfiguration; per-call ChatOptions override its stored defaults. chatWithConfiguration() / completeWithConfiguration() / streamChatWithConfiguration() accept an $optionOverrides array.

Changed 

  • The extension-configuration defaultProvider is consulted only when no usable default configuration exists. Defaults without a model, or with backend-group access restrictions, are skipped — group-restricted configurations are never auto-applied without a backend-user context.

Version 0.10.0 (2026-06-09) 

The specialized AI services (DALL-E, FAL, Whisper, TTS, DeepL) now authenticate through nr-vault's audited secure HTTP client instead of plaintext API keys, bringing them in line with the database-backed providers (ADR-012, ADR-030).

For the complete, itemised list see the canonical CHANGELOG.md.

Changed 

  • Specialized services authenticate through nr-vault. Each service stores an nr-vault secret identifier and authenticates via $vault->http()->withAuthentication(...); the secret is resolved, injected, audited, and memory-scrubbed inside the vault and never surfaces in this extension. FAL (Authorization: Key …) and DeepL (Authorization: DeepL-Auth-Key …) use the nr-vault 0.8.0 prefix option. DeepL's Free/Pro routing stays automatic via a one-time, scrubbed :fx suffix check.

Removed 

  • Plaintext API keys for the specialized services. Configuration keys are now nr-vault identifiers (providers.openai.apiKeyIdentifier, image.fal.apiKeyIdentifier, translators.deepl.apiKeyIdentifier). Requires netresearch/nr-vault ^0.8.0.

Version 0.9.0 (2026-06-08) 

This release migrates image generation to OpenAI's gpt-image-\* model family (DALL·E-3 was retired by OpenAI), makes chat JSON mode actually request JSON, and corrects the empty base-URL handling of the specialized services.

For the complete, itemised list see the canonical CHANGELOG.md.

Added 

  • gpt-image-\* image generation. ImageGenerationOptions accepts the gpt-image-* family by prefix and validates its size set (1024x1024 / 1536x1024 / 1024x1536 / auto); DallEImageService maps the family to a shared capability profile and sends a minimal payload (gpt-image rejects response_format / style / quality), reading the returned b64_json.

Fixed 

  • Chat JSON mode. OpenAiProvider now maps response_format=json to OpenAI's {"type":"json_object"} so CompletionService::completeJson() receives valid JSON instead of prose.
  • Empty base URL. An empty ext_conf baseUrl for the DALL·E, FAL and TTS services now falls back to the provider default instead of being used as a scheme-less request URL.

Version 0.8.0 (2026-06-02) 

This release adds usage analytics and turns on real cost tracking, and completes the provider middleware pipeline that now powers fallback, pre-flight budget enforcement, usage accounting, and response caching around every provider call. It also migrates the domain API to typed value objects.

For the complete, itemised list see the canonical CHANGELOG.md.

Added 

  • Usage Analytics dashboard. A new Admin Tools → LLM → Analytics submodule with cost and request trends, breakdowns by provider, model, and service, KPI tiles, and per-user usage against each user's monthly budget. The Providers, Models, Configurations, and Tasks list views also gained per-row Cost / Requests / Tokens (last 30 days) columns. See Usage analytics.
  • Real cost tracking. Usage is now priced from the configured model rates (prompt/completion token split), so the AI cost this month widget and the dashboard show real figures instead of 0. The tx_nrllm_service_usage table gained model and token-split columns plus per-task attribution.
  • Automatic budget pre-flight. Completion, embedding, translation, and vision requests are checked against the configured budget before the call is made.

Changed 

  • The domain API moved to typed value objects (chat messages, tool specs, vision content, capability sets, provider options). The legacy string/array options accessors are deprecated in favour of the typed equivalents.
  • Requires netresearch/nr-vault ^0.6.0.

Breaking 

  • The legacy Model::CAPABILITY_* class constants have been removed in favour of the ModelCapability backed enum (for example ModelCapability::CHAT->value). They had been deprecated since the enum was introduced.

Version 0.7.0 (2026-04-22) 

Added 

  • Provider fallback chain. LlmConfiguration can now list other configuration identifiers to retry against when the primary fails with a retryable error (connection / HTTP 5xx / 429 rate- limit). Non-retryable errors (4xx other than 429, configuration problems, unsupported feature) bubble up unchanged. Streaming is intentionally excluded from fallback because chunks cannot be replayed against a different provider. See ADR-021: Provider Fallback Chain and Fallback chain.
  • Attribute-based provider registration. New #[AsLlmProvider(priority: N)] attribute. Providers bearing the attribute are automatically tagged and made public by ProviderCompilerPass at container compile time; no services.yaml edit required. Legacy yaml tagging still works for third-party providers and takes precedence when both mechanisms are present. See ADR-022: Attribute-Based Provider Registration and Registering a provider.
  • Per-capability BE group permissions. Every ModelCapability enum value is now a native TYPO3 customPermOptions entry under the nrllm namespace. BE group editors see a checkbox per capability (chat, completion, embeddings, vision, streaming, tools, json_mode, audio). New CapabilityPermissionService resolves checks against the current BE user with admin short-circuit and CLI / frontend bypass. See ADR-023: Native Backend Capability Permissions and developer-capability-permissions.
  • Dashboard widgets. Two TYPO3 dashboard widgets sourced from tx_nrllm_service_usage: AI cost this month (NumberWithIconWidget) and AI requests by provider (7d) (BarChartWidget). Loaded conditionally from Configuration/Services.php only when typo3/cms-dashboard is installed. See ADR-024: Dashboard Widgets.
  • Per-user AI budgets. New tx_nrllm_user_budget table with six independent ceilings (requests / tokens / cost × daily / monthly). New BudgetService::check() aggregates usage on demand from tx_nrllm_service_usage — one DB roundtrip for both windows via conditional SUM(). Orthogonal to the existing per-configuration daily limits: both checks must pass. See ADR-025: Per-User AI Budgets and Per-user AI budgets.

Changed 

  • CI: mutation testing runs only on push, merge_group and schedule events. PR CI gets the fuzz suite + unit / functional / PHPStan / rector / code style; the  15 min mutation job is deferred because its per-PR signal is hard for authors to action locally.
  • CI: .semgrepignore added to exclude Tests/, Build/Scripts/ and vendor directories from Opengrep SAST. Previously failing on legitimate unlink() fixture cleanup.
  • CI: fuzz workflow now invoked with fuzz-testsuite: fuzzy matching the phpunit.xml suite name.

Version 0.6.0 (2026-03-24) 

Added 

  • DocumentCapableInterface: providers can now advertise PDF/document support; ChatCapabilitiesInterface exposes this via getProviderCapabilities().
  • Multimodal content arrays in chatCompletion: pass images, PDFs, and text blocks as structured content arrays alongside regular string messages.
  • Tool message conversion: tool_result blocks are now mapped correctly when assembling provider payloads.

Changed 

  • Migrated CI infrastructure to netresearch/typo3-ci-workflows shared workflows (PHP tests, docs, E2E).
  • Replaced GrumPHP with CaptainHook for pre-commit hooks.

Fixed 

  • PHPStan baseline regenerated; ignoreErrors patterns broadened for deprecation and array function rules to handle phpstan-typo3 v2/v3 parameter name differences.
  • E2E tests stabilised: heading verification added, module overview landing page assertions updated.

Version 0.5.0 (2026-03-09) 

Added 

  • AI-powered full-chain task wizard: describe a task in plain language, AI generates task + configuration + model recommendation in one step.
  • AI-powered configuration wizard: generate configurations with system prompts, parameters, and model selection.
  • Custom TCA ModelIdElement: input field with "Fetch Models" button that populates from provider API, auto-fills capabilities and pricing.
  • ModelConstraintsWizard: field wizard that loads parameter constraint bounds per model.
  • Dashboard improvements: side-by-side wizard callouts, fixed headline from "LLM Providers" to "LLM Integration".
  • Task execution UI: collapsible prompt details, improved result display.
  • Enhanced model discovery: better Anthropic, Google, DeepSeek, Mistral support.
  • TER publish workflow.
  • Documentation: wizards guide with screenshots, tasks section, updated configuration reference.

Changed 

  • Renamed SafeCastTrait extracted from duplicated helpers in TaskController and WizardGeneratorService.
  • SQL injection defense: regex whitelist validation for table/column names in FetchRecordsRequest and LoadRecordDataRequest.

Fixed 

  • Restored method_exists() guards for setShortcutContext() (TYPO3 v13 compatibility).
  • PHPUnit 12: replaced createStub with createMock to fix deprecation warnings.

Version 0.4.8 (2026-03-07) 

Changed 

  • Rewritten introduction with value-oriented positioning.
  • Restructured README around value proposition and audience segments.
  • Updated package metadata with value-oriented descriptions.
  • Added integration guide for extension developers.

Version 0.4.7 (2026-03-07) 

Added 

  • Help page in the LLM backend module.
  • Setup wizard links on empty-state list pages.

Fixed 

  • Use canonical endpoint URLs for known providers in setup wizard.
  • Remove container class from backend module templates.

Version 0.4.6 (2026-03-06) 

Fixed 

  • Add Fluid-compatible getHasApiKey() getter for {provider.hasApiKey} in templates.

Version 0.4.5 (2026-03-06) 

Fixed 

  • Use GET /v1/models for Anthropic connection test.

Version 0.4.4 (2026-03-06) 

Fixed 

  • Use table-specific connection and simplify column checks.
  • Wrap test cleanup in try/finally and assert labelField.

Version 0.4.3 (2026-03-06) 

Fixed 

  • Handle tables without uid column in TCA utilities.
  • Remove hardcoded temperature from chat completions.

Version 0.4.2 (2026-03-06) 

Fixed 

  • Add rootLevel to provider, configuration, and model TCA definitions.

Version 0.4.1 (2026-03-06) 

Fixed 

  • Use max_completion_tokens instead of max_tokens for OpenAI chat completions.

Version 0.4.0 (2026-03-06) 

Breaking 

  • Prevent plaintext API key storage via setup wizard; keys now require vault encryption.

Fixed 

  • Cast ExtensionConfiguration timeout values to integer.

Changed 

  • Use Symfony Uuid::v7() instead of manual UUID generation.

Version 0.3.2 (2026-03-04) 

Added 

  • Extract thinking blocks from LLM responses (<think> tag support).

Fixed 

  • Preserve newlines in extractThinkingBlocks.
  • Restrict CI push trigger to main branch only.
  • Add merge_group trigger to CI workflow.

Version 0.3.1 (2026-03-02) 

Fixed 

  • Add Overview submodule for TYPO3 v13 module overview compatibility.

Version 0.3.0 (2026-03-01) 

Added 

  • Expose chatWithConfiguration and streamChatWithConfiguration on LlmServiceManagerInterface.

Fixed 

  • Use integer values for f:be.infobox state attribute for TYPO3 v13 compatibility.
  • Explicitly enable fuzz and mutation tests.

Version 0.2.2 (2026-03-01) 

Fixed 

  • Use tools parent for TYPO3 v13 module compatibility.

Changed 

  • Consolidate caller workflows into 4 grouped files.
  • Fix documentation issues found by analysis.

Version 0.2.1 (2026-02-28) 

Changed 

  • Require netresearch/nr-vault ^0.4.0 for API key encryption.

Version 0.2.0 (2026-02-28) 

Added 

  • PHP 8.2+ and TYPO3 v13.4+ compatibility.
  • TYPO3 v13.4 ddev install command.
  • Coverage uploads and fuzz/mutation CI workflow.
  • Unit tests for enums, WizardResult DTO, providers, services, and specialized classes.
  • Coverage tests for PromptTemplateService and TranslationService.

Changed 

  • Moved phpunit.xml and phpstan-baseline.neon into Build/ directory.
  • Expanded CI matrix to PHP 8.2-8.5 and TYPO3 v13.4/v14.
  • Replaced TYPO3 v14-only APIs with v13-compatible equivalents.
  • Narrowed testing-framework to ^9.0 for PHPUnit 12 compatibility.
  • Removed dead ProviderRegistry class and orphaned phpstan baseline file.
  • Removed 55 dead translation keys.
  • Harmonized composer script naming to ci:test:php:* convention.
  • Migrated CI to centralized workflows.
  • Added SPDX copyright and license headers.
  • Replaced generic emails with GitHub references.

Fixed 

  • Resolved CI failures for PHP 8.2 and TYPO3 v13 compatibility.
  • Resolved PHPStan failures for dual TYPO3 v13/v14 support.
  • Fixed PHPUnit deprecation warnings.
  • Used CoversNothing for excluded exception and enum test classes.
  • Localized user-facing hardcoded strings in controllers.
  • Disabled functional tests in CI (environment-specific).
  • Fixed direct php-cs-fixer call in ci:test:php:cgl script.

Version 0.1.2 (2026-01-11) 

Fixed 

  • Fixed CI: use correct org secret name for TER token.
  • Simplified TER upload workflow.

Version 0.1.1 (2026-01-11) 

Fixed 

  • Fixed CI: create zip archive for TER upload.

Version 0.1.0 (2026-01-11) 

Initial release of the TYPO3 LLM Extension.

Added 

Core Features

  • Multi-provider support (OpenAI, Anthropic Claude, Google Gemini, Ollama, OpenRouter, Mistral, Groq).
  • Unified API via LlmServiceManager.
  • Provider abstraction layer with capability interfaces.
  • Typed response objects (CompletionResponse, EmbeddingResponse).
  • Three-tier configuration architecture (Providers, Models, Configurations).
  • Encrypted API key storage using sodium_crypto_secretbox.

Feature Services

  • CompletionService: Text completion with format control (JSON, Markdown).
  • EmbeddingService: Vector generation with caching and similarity calculations.
  • VisionService: Image analysis with alt-text, title, description generation.
  • TranslationService: Translation with formality control and glossary support.
  • PromptTemplateService: Centralized prompt management with database-driven templates.

Specialized Services

  • Image generation (DALL-E).
  • Text-to-speech (TTS) and speech transcription (Whisper).
  • DeepL translation integration.

Provider Capabilities

  • Chat completions across all providers.
  • Embeddings (OpenAI, Gemini).
  • Vision/image analysis (all providers).
  • Streaming responses (all providers).
  • Tool/function calling (all providers).

Infrastructure

  • TYPO3 caching framework integration.
  • Backend module for provider management and testing.
  • Prompt template management with versioning and performance tracking.
  • Comprehensive exception hierarchy.
  • Type-safe enums and DTOs for domain constants.

Developer Experience

  • Option objects with factory presets (ChatOptions).
  • Full backwards compatibility with array options.
  • Extensive PHPDoc documentation.
  • Type-safe method signatures.

Security

  • Enterprise readiness security workflows and supply chain controls.
  • SLSA Level 3 provenance, Cosign signatures, and SBOM generation.
  • OpenSSF Scorecard and Best Practices compliance.

Testing

  • Comprehensive unit and integration tests.
  • E2E testing with Playwright.
  • Property-based (fuzz) testing support.

Upgrade Guides 

Upgrading from Pre-Release 

If you used a pre-release version:

  1. Remove old extension

    Remove old extension
    composer remove netresearch/nr-llm
    Copied!
  2. Clear caches

    Clear caches
    vendor/bin/typo3 cache:flush
    Copied!
  3. Install current version

    Install current version
    composer require netresearch/nr-llm:^0.2
    Copied!
  4. Run database migrations

    Run database migrations
    vendor/bin/typo3 database:updateschema
    Copied!
  5. Update configuration

    Review your TypoScript and extension configuration for any changed keys or deprecated options.

Breaking Changes Policy 

This extension follows semantic versioning:

  • Major versions (x.0.0): May contain breaking changes
  • Minor versions (0.x.0): New features, backwards compatible
  • Patch versions (0.0.x): Bug fixes only

Breaking Changes Documentation 

Each major version will document:

  1. Removed or changed public APIs
  2. Migration steps with code examples
  3. Compatibility layer availability
  4. Deprecation timeline for removed features

Deprecation Policy 

  1. Features are marked deprecated in minor versions
  2. Deprecated features remain functional for one major version
  3. Deprecated features are removed in the next major version
  4. Migration documentation provided before removal

Sitemap