# ADR 0023: Read-access audit trail limited to classified content - Status: proposed - Date: 2026-07-29 ## Context The existing audit trail (`audit_log`, issue #86) is deliberately scoped to "who changed access or configuration", and its own service comment states that content activity stays log-only. There is no record of _reads_. For "operable in an approved environment", read logging is not a mandatory product feature — evidence collection can be a platform function. In practice platform logging cannot answer the question that matters: a proxy log knows URLs, not classifications, so it cannot say which _classified_ page was read. Leistungsbeschreibungen tend to list this as a must. Two variants were considered. Variant B logs all reads (18–20 AT) and brings volume, latency and retention problems, plus the requirement that no event may be lost. Variant A logs reads of classified pages only (8–10 AT). A live editing session is the volume hazard: Yjs sync means continuous traffic per open document. ## Decision **Variant A: read events are recorded only for pages with `classification = VS_NFD`.** Requires ADR 0022. 1. **All read channels are instrumented**, or the feature is worthless: SPA page fetch, public API GET, attachment download, export, no-JS shell, collab WS join. 2. **A dedup window** (session + page + channel within N minutes = one event) keeps Yjs sync from flooding the trail. The recorded event states that it represents a window, so the evidence is not overread. 3. **Its own table**, with time partitioning and its own retention period — independent of `audit_log`, because volume, purpose and legal basis all differ. 4. **Failure is not silent.** `AuditService` swallows write failures by design; for classified reads a lost event is a gap in evidence, so the behaviour is either hard failure or an explicitly documented degradation. Which one is decided in #222 and stated in the security documentation. 5. **Switchable, with a written purpose limitation.** Off means nothing is written anywhere; a startup log line states the trail is off so a gap is never ambiguous. 6. **Variant B is rejected**, and the rejection is recorded rather than left open: unbounded volume, the no-loss requirement, and a purpose limitation that is much harder to defend. ## Decisions taken in #222 - **Failure mode: hard failure.** A failed `read_events` write aborts the read with the ordinary 500. The alternative (documented degradation) was rejected: the reader retrying is cheap, a gap in evidence is not. This is the deliberate contrast to `AuditService`, which swallows failures. - **Collab WS join: the api emits at token issuance.** The collab server keeps zero permission/classification context; tokens live 60 s, so a live session re-requests one per minute — per-minute granularity for free, which the dedup window (#223) collapses. The trail therefore proves "held a live connection during this window", not individual sync frames. - **Scope: full-content channels.** Content _fragments_ (search snippets, task-overview rows, backlink titles) and the Atom feeds (off in the reference configuration) are deliberately not instrumented — recorded in `security.md` §Logging as a residual. - **Session key vocabulary:** `session:` (cookie), `token:` (PAT — also the MCP `read_page` path), `job:` (background builds such as the account data export), `anon` (anonymous reader on a public grant). ## Decisions taken in #223 - **Aligned dedup windows.** One event per (session, page, channel) within an aligned window of `readTrail.dedupWindowMinutes` (default 5): buckets are `floor(epoch / windowSeconds)`, and a unique (dedupKey, windowBucket) pair collapses concurrent duplicates race-free at insert time. Sliding windows (measured from the first event) were rejected — they need a read-before-write and lose the race-safety of the plain unique insert. Consequence: two reads just across a bucket boundary yield two events; over-recording is acceptable, gaps are not. - **A skipped duplicate is not a gap** — the unique-violation path returns quietly (debug log), only real write failures abort the read. - **Each row states its window** (`windowSeconds`), so the evidence reads as "accessed at least once in these N minutes", never as a request count. What the trail can prove about a live session: the per-minute collab-token renewals collapse to one `collab_join` event per window — presence during the window, not activity within it. - **Anonymous readers share one `anon` key**: all anonymous reads of a page through one channel inside a window are one event. Deliberate — telling anonymous browsers apart would require fingerprinting (IP/UA), which the purpose limitation (#225) rules out. ## Decisions taken in #224 - **Monthly RANGE partitions** on `occurred_at`, plus a DEFAULT partition as a safety net: a lagging maintenance job must never make classified reads fail (the trail write is hard-failing — an ops miss must not become an outage). Retention DROPs whole expired months without scanning; the pruning run is audited (`read_trail.pruned`) under the trail's own `readTrail.retentionDays` (default 365). - **The dedup unique pair lives per partition** (PostgreSQL cannot carry it on the parent without the partition key). A dedup bucket spanning a month boundary can therefore record one duplicate — over-recording is acceptable, gaps are not. - **Query path is API-only** (`GET /admin/system/read-events`, Site Admin): the trail is an examiner's tool, not a daily screen. Evidence nobody can read is not evidence — the path exists; a panel does not. - **Growth measured**: ~1 MB per 1000 events including indexes. ## Decisions taken in #225 - **Default OFF** (`readTrail.enabled`): read logging is employee monitoring in a works council's eyes — an ordinary instance must not surveil reads. The VS-NfD reference configuration turns it on together with the written purpose limitation (`docs/vs-nfd/60-sicherheitsdokumentation.md` §7, referenced from the hardening guide). - **Off means silence everywhere** — no row, no stdout line; the api announces the switch position once per boot, so an eventless trail is never ambiguous (a gap reads as "was off", not "was lost"). - The purpose limitation names what is recorded, why, who may read it (Site Admin, API-only), for how long, and what it may NOT be used for (no performance or behaviour monitoring). Its technical anchor is the variant-A scope: unmarked content produces no trace. ## Consequences - The scope limit is the feature's strongest argument in the works-council discussion at the customer: only classified content is observed. - Reads of unclassified content are not evidenced. Deliberate, and it goes into the residual-risk list. - The collab WS join is the awkward channel: authorization there is token-only (signature plus `pageId` match) and the collab server has no permission context. Either the event carries what the token asserts, or the api emits it at token issuance. #222 decides and documents; the choice affects what the trail can prove about live sessions. - Retention and partition maintenance are operational obligations that must ship with the feature, not after it. - Classification at read time is stored with the event: a later reclassification must not rewrite history. ## Implementing issues #222 (instrumentation), #223 (dedup window), #224 (table, retention, partitioning), #225 (switch + purpose limitation). Depends on #204/#205.