---
title: "ADR-064: Central Privacy Model"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-064@0.35"
source: "Adr/Adr064CentralPrivacyModel.rst"
modified: "2026-09-16T22:09:16+00:00"
---

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

-   `tx_nrllm_eval_result.details` — a JSON snapshot of the per-prompt graded
    model output ([ADR-060: Quality evaluation — golden sets, grading and regression detection](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-060@0.35)). This is real model output.
-   `tx_nrllm_skill_audit.scan_result` and `detail` — the
    prompt-injection scan output and a free-form detail string ([ADR-061: Skill trust levels, signed manifests, injection scanning](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-061@0.35)).

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 default** — `PrivacyPolicy::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-058: Telemetry Middleware](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-058@0.35) — Telemetry Middleware (metadata-only by construction; named
    this workstream as the follow-up).
-   [ADR-060: Quality evaluation — golden sets, grading and regression detection](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-060@0.35) — Quality Evaluation (owns `tx_nrllm_eval_result`).
-   [ADR-061: Skill trust levels, signed manifests, injection scanning](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-061@0.35) — Skill Trust, Egress and Audit (owns
    `tx_nrllm_skill_audit`).
-   ADR-017 — SafeCastTrait / ErrorMessageSanitizerTrait (the shared credential
    redaction reused here).
