---
title: "Effective policy"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:administration-governance@0.35"
source: "Administration/Governance.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# 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

**AI > 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](https://docs.typo3.org/permalink/netresearch/nr-llm:administration-governance-no-apply@0.35)).

## 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](https://docs.typo3.org/permalink/netresearch/nr-llm:administration-data-retention-what-is-stored@0.35).

-   **`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](https://docs.typo3.org/permalink/netresearch/nr-llm:administration-data-retention-purging@0.35)).

-   **`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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-113@0.35)).

-   **`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](https://docs.typo3.org/permalink/netresearch/nr-llm:administration-skills-isolation@0.35).

## 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](https://docs.typo3.org/permalink/netresearch/nr-llm:administration-data-retention-how-long@0.35).

## Recommended settings

The shipped defaults are a safe posture for a new installation. Nothing has
to be set to reach it — this block only makes it explicit:

**Default posture — Install Tool, Extension Configuration, nr_llm**

```text
privacy.level              = metadata
privacy.retentionDays      = 30
tools.dataClassEnforcement = enforce
skills.minTrustLevel       = untrusted
```

For an installation that handles personal data, or that runs skills from
sources it does not publish itself:

**Stricter posture**

```text
privacy.level                   = metadata
privacy.retentionDays           = 7
privacy.retention.conversation  = 3
privacy.retention.approval      = 30
tools.dataClassEnforcement      = enforce
skills.minTrustLevel            = verified
```

What changes against the default: rows are gone within a week and
conversation transcripts within three days, while runs waiting for an
approver survive a month; skills below `verified` are dropped from prompt
injection and from the allowed-tools union without being deleted, so
re-classifying a source brings them back.

`privacy.level` stays at `metadata` on purpose. `none` is not stricter:
both drop the content payload and keep the metadata, and no code path treats
them differently. Only `redacted` and `full` write content at all — pick
either deliberately, and never on an installation that must not hold request
content.

> [!WARNING]
> Switching `tools.dataClassEnforcement` from `enforce` to `observe`
> turns the data-class axis off **instance-wide** — every configuration,
> every run, every user. Over-ceiling tools are offered again and only
> recorded. It is a diagnostic mode, not a setting to leave in place.
>
> Two things stay strict regardless: the other gate axes (unregistered
> tool, disabled tool, admin-only tool, the configuration's allowed tool
> groups) always enforce, and tools from an
> [MCP server](https://docs.typo3.org/permalink/netresearch/nr-llm:administration-mcp-servers@0.35) never observe — a remote
> tool above the ceiling is removed even in observe mode.

### After an upgrade from a version that predates `enforce`

An upgrade wizard exists to keep such an installation on `observe`, so
the changed default does not strip tools from a working setup
([ADR-115](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-115@0.35)). It only takes effect if you run it —
**Admin Tools > Upgrade > Upgrade Wizard**, or
`vendor/bin/typo3 upgrade:run` — and only while the installation still
qualifies: at least one provider configured, and no enforcement mode stored
yet.

The second condition expires on its own, often before anyone opens the
wizard list. TYPO3 writes the shipped defaults of an extension into
`settings.php` when `extension:setup` runs and again whenever an admin
enters the Install Tool. That write stores `enforce`, and a stored value
is what the wizard reads as "the operator already chose". From then on it
reports nothing to do and disappears from the list.

So do not assume the pin happened. Open the **Governance** tab
after the upgrade and read the row:

-   **`observe`**

    The wizard ran, or the value was already stored — from an earlier
    explicit choice, or from the default the older version shipped. Either
    way your runs are unchanged: over-ceiling tools are offered and
    recorded.
    Work through [Telling whether it works](https://docs.typo3.org/permalink/netresearch/nr-llm:administration-governance-verify@0.35), fix what
    enforcement would remove, then set `enforce`.

-   **`enforce`**

    Nothing preserved the old behaviour. The gate has been removing
    over-ceiling tools from every run since the update, without an error
    anywhere a user would see. The removals are recorded, so you can read
    what was lost instead of guessing — see
    [Telling whether it works](https://docs.typo3.org/permalink/netresearch/nr-llm:administration-governance-verify@0.35). Two ways forward: fix those
    configurations and stay on `enforce`, or set `observe` while you
    work through them and switch back afterwards.

## 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: tool-gate
    denials, guardrail response blocks, and the guardrail's own
    `approval_required` verdict on a response.

    A `write_unapproved` bar counts writes the runtime refused because
    nothing had approved them: a pending call that reached the input-resume
    path after its tool was switched on mid-run. It is a refusal, not an
    approval record, which is why it belongs in this table.

    The `approval_required` bar is **not** a count of tools held for human
    approval. A tool-approval suspend writes no row in this table,
    deliberately: the approval trail lives on the run itself, under the
    `approval` retention window described above, because a run waiting for
    an approver carries resumable work and must outlive the telemetry-length
    window this table uses. Read it per run in the run timeline, not here.

The **Trust zone ceiling** 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**

```sql
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;
```

`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 five gates listed below
and reports one verdict plus each gate's own answer
([ADR-157](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-157@0.35), [ADR-167](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-167@0.35)).

The verdict is one of three:

-   **`Allowed`**

    All five gates permit the call and it would run unattended.

-   **`Allowed, after a human approves`**

    All five gates permit the call, and the tool is approval-bound
    ([ADR-134](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-134@0.35)): 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 five refuses. The table says which.

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

| Gate | What it decides | Depends on the actor? |
| --- | --- | --- |
| Configuration access ([ADR-070](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-070@0.35)) | whether this backend user may use this configuration at all, given the backend groups it is restricted to | **Yes** — through the user's group membership |
| Tool gate ([ADR-094](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-094@0.35)) | 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-144@0.35)) | whether the snippets and skills this configuration injects may reach the trust zone it can send to | No |
| Routing ([ADR-142](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-142@0.35)) | whether any model resolves for a tool-calling run at all | No |
| Human approval ([ADR-134](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-134@0.35)) | whether the tool is bound to an operator decision | No |

**Two axes are 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. The two that read the
user are the tool gate, through the tool's `requiresAdmin()`, and
configuration access, through the backend groups the record carries. A picker
that implied five per-user answers where there are two would be worse than no
picker.

The configuration selector lists every active configuration and applies no
membership filter, so a group-restricted configuration paired with a
non-member is a pairing the picker makes easy to produce. The tab asks
`ConfigurationResolver` the same question the runtime asks it, so that
pairing reads `Blocked` here and names the restriction as the reason
([ADR-167](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-167@0.35)).

**Two things that can stop a real call are not asked here**, so `Allowed`
does not promise them: 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 budget check has a second reason, stated in
[ADR-167](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-167@0.35): it reports usage and limit numbers only when it
refuses, so "close to the limit" and "no data" cannot be told apart from
"plenty left", and a row saying otherwise would be a measurement nobody took.

**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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-083@0.35)): 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-157@0.35).

**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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-148@0.35)).

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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-142@0.35)). 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-156@0.35)).

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 the send carries, 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-156@0.35) 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](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-143@0.35)). 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.

## What a call cost, and what it was before a model was chosen

The same rows carry two further groups that the page does not show. They are
queried directly against `tx_nrllm_telemetry` ([ADR-174](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-174@0.35)),
which is why they are described here rather than pointed at on screen.

**What the call consumed.** `actual_input_tokens`, `actual_output_tokens`,
`actual_cost`, `response_model` and `provider_retries`. The cost table
`tx_nrllm_service_usage` holds the same figures as a daily aggregate with no
per-call key, so a cost there cannot be attributed to one request; these columns
can, because they sit on the row the correlation id identifies.

**What the request was, before anything chose a model.** `facts_messages`,
`facts_turns`, `facts_tools`, `facts_payload_bytes`,
`facts_token_estimate` and `facts_shape`. The complexity group above is
measured after the model is chosen and partly from it — its utilisation is a
property of the chosen window — so it describes a decision and cannot inform
one. This group is model-independent: no window, no price, no chosen model.

The counts describe the transcript the caller sent, not the bytes that left the
server: the configuration's system prompt is added afterwards, from options that
only exist once a model is resolved. A system message the caller wrote is
counted. `complexity_payload_bytes` and the complexity shape are measured the
same way, before that prompt is applied — but on the *pruned* list, so on a row
where the fit dropped turns the pre-routing byte count is the larger of the two.

The two token estimates are not. `facts_token_estimate` is the raw estimate of
the caller's transcript and its tool schemas. `complexity_tokens` is the
context fit's figure. Three things separate them, and only the first is on every
row:

-   **The calibration factor.** The fit scales its estimate by a factor that starts
    at 1.15 and only grows toward the prompt-token counts providers report; the
    fact group is estimated with the same estimator at 1.0. Two identical lists
    still differ by at least fifteen percent, and `complexity_tokens` is the
    larger one.
-   **Pruning**, on the rows where the fit dropped turns: `complexity_tokens`
    describes the bounded list, `facts_token_estimate` the one the caller sent.
-   **The configuration's system prompt**, on the rows whose transcript does not
    already open with a system message. The fit charges it on top of the
    transcript; the fact group never sees it.

An injected skill block is *not* a fourth difference. On every path that writes
both columns it is already inside the transcript by the time the fact group is
measured, so both figures carry it. Subtract the three above before reading
anything into the gap.

**NULL means nobody measured, and is never a zero.** A model priced at zero
records real tokens and no cost, which is what separates "this arm was free"
from "nobody priced this arm" — the distinction any cheap-model comparison rests
on. Token counts are NULL where the provider reported no usage block. The fact
group is NULL across the board with `facts_shape = ''` on the paths that
measure nothing, exactly as `complexity_shape` flags the complexity group.
`provider_retries` is the one column that is always written — `0` there is a
measured "no retry", and rows that report NULL are the ones written before the
column existed.

A streamed run records no token counts. It carries a character-based estimate
rather than a provider usage block, and the columns are named `actual`.

Cost per request shape over the last thirty days, for example:

```sql
SELECT facts_shape,
       COUNT(*)                AS calls,
       AVG(actual_cost)        AS mean_cost,
       SUM(actual_cost)        AS total_cost,
       AVG(actual_input_tokens)  AS mean_in,
       AVG(actual_output_tokens) AS mean_out
  FROM tx_nrllm_telemetry
 WHERE crdate >= UNIX_TIMESTAMP() - 30*86400
   AND facts_shape <> ''
 GROUP BY facts_shape;
```

`AVG` skips NULLs, so an installation running free local models reports a NULL
mean cost for those rows instead of pulling the average toward zero. Filter on
`actual_cost IS NOT NULL` when you want the priced population only.

**Nothing routes on any of it**, exactly as for the complexity group above. The
criteria in [ADR-156](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-156@0.35) still decide, and these columns exist so
they can be evaluated.

## Why there is no apply button

The page is read-only on purpose ([ADR-140](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-140@0.35)), 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.
