ADR-056: Configuration presets — consumer-declared, admin-imported records
- Status
-
Accepted
- Date
-
2026-07-13
- Authors
-
Netresearch DTT GmbH
Note
The backend-module UI named below as a follow-up has been implemented
since: the Configurations backend module lists pending presets with
their preflight result and imports one via the existing
nrllm_preset_import endpoint, and imported records whose
declaration checksum drifted are flagged with a non-blocking hint
(see Importing configuration presets). The
checksum-driven update flow (diff + re-confirm) remains future
work.
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.tool — ToolInterface /
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.
- Declaration via DI tag. A consumer implements
ConfigurationPresetProviderInterface(tagnr_llm.configuration_preset, auto-applied byAutoconfigureTag, mirroringToolInterface) and returnsConfigurationPresetvalue objects.ConfigurationPresetRegistrycollects them through a tagged iterator and fails fast on duplicate identifiers. - 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. - Imported records are criteria-mode configurations. Import creates
the record with
model_selection_mode = criteria, soModelSelectionServiceresolves it on every run against whatever providers and models the admin has configured. The admin keeps full control: the record is a normaltx_nrllm_configurationrow, editable and deletable like any other. - Checksum idempotency. The preset's SHA-256 checksum over a
canonical JSON encoding of all declared fields is stored in the new
preset_checksumcolumn (typepassthrough, 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. - Preflight before import.
ConfigurationPresetImportServicechecks the criteria through the veryModelSelectionServicethat 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. - 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; an update flow (diff + re-confirm) is deliberately deferred. (Still the case: the module UI shows a drift hint, nothing more.)
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.