Convert read_events to monthly RANGE partitions on occurred_at, with a DEFAULT partition as safety net: a lagging maintenance job must never turn the trail's hard-failure semantics into an outage for classified reads. The dedup unique pair (#223) moves to per-partition indexes (PostgreSQL cannot carry it on the parent); a bucket spanning a month boundary may record one duplicate — over-recording is acceptable, gaps are not. New daily job read-trail-maintenance (job-count fence 9 -> 10) creates months ahead — each with its dedup index — and applies the trail's own retention readTrail.retentionDays (default 365, deliberately independent of audit.retentionDays): whole expired months are DROPped without scanning, remainders deleted by range, every run audited as read_trail.pruned (catalogue v1.2; the fence regex now admits an underscore namespace). Site-Admin query path GET /admin/system/read-events answers "who read page X" and "what did user Y read" within a period — API-only by design, documented. Growth measured and documented in data-model.md: ~1 MB per 1000 events including indexes. Tests: retention pruning + audited deletion + admin queries on the shared database; the partitioned shape, per-partition P2002 dedup, months-ahead creation and DROP-based pruning against a fresh database built by the real migration chain. Refs #224. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
6.7 KiB
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.
- 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.
- 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.
- Its own table, with time partitioning and its own retention period —
independent of
audit_log, because volume, purpose and legal basis all differ. - Failure is not silent.
AuditServiceswallows 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. - 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.
- 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_eventswrite 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 toAuditService, 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:<id>(cookie),token:<id>(PAT — also the MCPread_pagepath),job:<id>(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 arefloor(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 onecollab_joinevent per window — presence during the window, not activity within it. - Anonymous readers share one
anonkey: 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 ownreadTrail.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.
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
pageIdmatch) 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.