---
title: "ADR-140: The effective-policy readout has no apply path"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-140@0.35"
source: "Adr/Adr140EffectivePolicyReadoutWithoutApplyPath.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-140: The effective-policy readout has no apply path

-   *Status:* Accepted (the readout gains consumers — see [ADR-145](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-145@0.35))
-   *Date:* 2026-08-09
-   *Amended:* 2026-08-10 by [ADR-145](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-145@0.35)

## 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.
1.  **The value shown is what the gate DOES, not the literal setting.**
    `tools.dataClassEnforcement` is fail-closed ([ADR-113](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-113@0.35)):
    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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-115@0.35)). 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.
1.  **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.
1.  **No apply path.** See below — this is the actual decision.
1.  **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()`
    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.
1.  **No new backend module.** [ADR-119](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-119@0.35) 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\`\`** and documented as *"Set a full extension
    configuration"* — it takes the whole array and calls
    `setLocalConfigurationValueByPath('EXTENSIONS/' . $extension, $value)`.
    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()` does
    exactly this: `get()` → mutate one key → `set()`. Every default in
    `ext_conf_template.txt` becomes an explicitly stored value.
    `storedEnforcement()` — the "did the operator ever choose
    a mode?" probe [ADR-115](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-115@0.35)'s wizard depends on — could then never
    return `null` again.
-   **\`\`additional.php\`\` spoils it.** The core docblock on
    `ExtensionConfiguration::set()` says so outright: 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](https://docs.typo3.org/permalink/netresearch/nr-llm:administration-data-retention@0.35)).
-   **"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.
