ADR-0110: The Audit Trail Is Not a Read Model¶
- Status: accepted (2026-08-09 — "ADR 0110 looks good too")
- Date: 2026-08-09
- Deciders: Nicolas Burri (the register-#30 redirect — "instead of going the audit trail route, wouldn't it be easier to add the information to the database?" — and, on where the rule belongs: "I would prefer it in an ADR. If it helps, we may also add it additionally as an architecture note")
- Relates to: ADR-0029 (the append-only trail and its guarantee), ADR-0107 §2 (audit reach follows VIEW — the reading rule for the trail's own surfaces), ADR-0109 §4 (the emptiness oracle — the one deliberate boundary case), register 2026-08-06 #30 (the near-miss that prompted writing the rule down), V088 (its realization).
Context¶
Register #30 asked for the PDF identity block to name who released a version. The first
implementation route read the answer from the audit trail at export time: the RELEASED event
carries the actor, the caller's audit reach covers every document they may export, so the query
is correct. It was still the wrong design, and Nicolas redirected it mid-flight: the release
actor became columns on document_version (V088), backfilled once from the trail, written
henceforth by the release acts themselves.
The sweep that followed found the invariant had held everywhere else by accident: no
main-source code outside the audit module selects from audit_event. This ADR makes the
accident a rule, because the second violation is always cheaper to justify than the first —
"the trail already knows" is true for nearly every lifecycle fact in the system, and each use
would quietly couple business behavior to the trail's internals.
Decision¶
The trail is written by every act and read only to be shown or counted — never to answer a business question at runtime.
- Evidence, not state. The audit trail records that acts happened (ADR-0029: append-only, Part 11-shaped). Domain tables carry the state those acts produced. When a surface needs a lifecycle fact — who released, who invited, when something changed hands — that fact lives on the domain row, written in the same transaction as its trail event. If a needed fact exists only in the trail, the fix is a migration that promotes it to state (the V088 shape: add the column, backfill once from the trail, dual-write from then on) — not a runtime trail read.
- What may read the trail:
- Display surfaces that present the trail as the trail (Admin › Activity, per-scope activity, entity histories) — under the ADR-0107 §2 reading rule, unchanged.
- Evidence exports that carry the trail itself (the validation journey's bundle, audit packs including trail extracts).
- Existence counts, where the fact of recording is itself the domain question. Sole current case: ADR-0109 §4's emptiness oracle — an organization that ever recorded anything stays permanent. Counts only, never row content.
- One-time migrations backfilling promoted facts (each names this ADR).
- Tests, asserting that acts leave their evidence.
- What may not: any runtime path where trail row content becomes input to rendering, authorization, validation, computation, or any other business behavior. No exceptions by convenience; a new legitimate category amends this ADR first.
Why (so the rule survives its author)¶
- Coupling: trail rows are versioned prose-and-payload evidence; their shape serves the reader of history, not the runtime. A business feature depending on event names or payload keys makes every future trail refinement a breaking change.
- Reach mismatch: trail reading follows VIEW (ADR-0107 §2); domain facts follow their own authority. The #30 route happened to be safe only because export requires VIEW — the next use would inherit that subtlety unexamined (the #21 blind-write family shows how such assumptions rot).
- Honesty both directions: state answering questions and trail evidencing acts can be checked against each other (as the V088 backfill did). Collapse them and the check is gone.
Consequences¶
- V088 realizes the rule for the release act; its header cites this ADR.
- The conventions checklist gains the one-line rule with a pointer here (Nicolas: an architecture note in addition is fine) — arc42 §8's audit paragraph gets the same pointer.
- Reviewers and agent briefs treat a
from(AUDIT_EVENT)outside the audit module, migrations, and tests as a finding by default.
Amendment 2026-08-21 — the readers, named; and the rule restated on PURPOSE, not on module¶
The 2026-08-18 review arc found this ADR factually stale, and Nicolas ruled the fix on 2026-08-21 (fix-log decision 5, option (a)): "There are now FOUR readers … Amend it to name the legitimate readers and restate the rule." Enumerating them found more than four — and the oldest of them is five weeks OLDER than this ADR, which the sweep that produced this ADR did not see.
1. What is false in the text above¶
- "Sole current case: ADR-0109 §4's emptiness oracle" (§2, third bullet). There are six
runtime readers outside
backend/audittoday, plus two SQL readers and one migration (§2 below). - "A new legitimate category amends this ADR first" (§3). It has been skipped twice. ADR-0114 §7 (2026-08-11) declared two readers — the dissolution tripwire's no-local-release oracle and the change inventory's act counts — in a decision that never cites this ADR. And the four-eyes read below shipped on 2026-07-05, before this ADR existed, so it was never a "new category" anyone could have been asked to declare; it was simply missed.
- The ruling's own list is already one entry out of date:
ImportBatchRepository.hasLocalReleaseno longer exists. V106 (2026-08-19) folded it into the single SQL oracle, which is what removed the third copy of that rule.
2. The readers, enumerated (verified against the tree, 2026-08-21)¶
| # | Reader | What it reads | What for | Verdict |
|---|---|---|---|---|
| 1 | ~~WorkflowActionEvaluator.submitterUnderFourEyes (document)~~ |
~~the ACTOR of a version's latest SUBMITTED (latestActorFor)~~ |
the four-eyes bar: the round's submitter may not approve it (ADR-0046/-0047) | RETIRED the same day — see §7. Reads document_version.submitted_by (V107) |
| 2 | ~~TaskService.blockedSubmitterVersions (notification)~~ |
~~the same, batched (latestActorsFor)~~ |
the same predicate over a whole task page | RETIRED with 1 — reads the same column, via the row the inbox already fetched |
| 3 | ImportBatchRepository.workingRecordEvidence (document) |
V106's oracle (below) | the dissolution tripwire's refusal, and the preview that must agree with it | legitimate — enforcement oracle |
| 4 | ImportBatchRepository.actCountsSince (document) |
action + entity_type of the scope's rows since finalize — the ONLY from(AUDIT_EVENT) outside backend/audit |
ADR-0114 §7's "these will be destroyed" inventory (staffing / configuration counts) | legitimate — counts of acts |
| 5 | DocumentService.auditTrail (document) |
the document's and its versions' rows (readForEntities) |
the entity-history display surface | legitimate — §2's first bullet |
| 6 | DocumentService.listVersions (document) |
the OCCURRENCE TIME of REVOKED (actionTimesByEntity) |
invalidSince on the version-history screen |
violation, declared in place: revocation has no promoted column yet and the KDoc names the V088-shaped migration as the fix |
| 7 | lqms_working_record_evidence (V106 §7, SQL) |
existence of RELEASED; counts of five acts that leave no row of their own |
the tripwire itself — read by both dissolution functions and by the preview | legitimate — enforcement oracle; it also create or replaces V095's inline copy, so this is now the only live SQL guard |
| 8 | lqms_mandator_contents (V087, SQL) |
count(*) over every table with a scope/mandator FK, audit_event among them |
ADR-0109 §4's emptiness oracle | legitimate — the case §2 already named |
| 9 | V088's backfill (migration) | earliest RELEASED per version |
the one-time promotion this ADR prescribes | legitimate — §2's fourth bullet, and it cites this ADR |
Reader 4 carried a defect of exactly the kind an unwatched trail read invites, fixed under the same
ruling: its content-entity list named "COMMENT" while CommentService has always written
"DOCUMENT_COMMENT", so every comment act was reported to the operator as a configuration act on
the pre-dissolution screen. ContentActClassificationTest now drives real acts and compares the
reading side with the writing side, because nothing did.
3. What the enumeration says about the rule¶
The Context above records that the sweep "found the invariant had held everywhere else by
accident: no main-source code outside the audit module selects from audit_event." That sentence
was literally true and substantively false, and the gap is the lesson: there are two ways to
read the trail, and it names one. from(AUDIT_EVENT) is the jOOQ way — exactly one reader outside
backend/audit uses it. The other way is to call the audit module's own repository, which four of
the six did, including the one that decided who may approve a document. A rule policed by a grep
for the first form is blind to the second, and stayed blind for five weeks across a review that was
looking directly at it. (Both of those readers were removed hours after this was written — §7 — which
does not soften the lesson: nothing FOUND them; enumerating for this amendment did.)
So the module boundary is the wrong line. backend/audit is not where trail reads become dangerous —
it is merely where the SQL happens to be written. What makes a read dangerous is what the answer is
used for, and that is what the "Why" section above was always arguing.
4. The rule, restated¶
The trail may be read at runtime only where an ACT is genuinely the question. A fact about a THING is never asked of the trail — it belongs on the thing's row.
Three permitted purposes, and the direction test that separates them:
- Show the trail as the trail — histories, activity feeds, evidence exports. Unchanged (ADR-0107 §2 reading rule).
- Ask whether an act happened — its existence, its count, its actor, its instant, for a
named action. Permitted where the ACT is what the question is about ("did anyone release
here?", "how many acts would this destruction destroy?"), and
subject to three conditions: the read takes only existence / count / actor / instant of a named
action — never
changespayload, never prose; the reading site cites this ADR and says in one sentence why domain state cannot answer; and where state could carry it, the promotion is registered as a migration rather than left implicit. - One-time migrations and tests — unchanged.
The direction test. If the answer is a property of a THING — who released this version, when this version was revoked, when this document took effect, who owns the review round open on this version right now — it is state, and reading it from the trail is the V088 mistake regardless of which module the query lives in. If the answer is a property of an ACT — whether any release act happened in this scope, how many acts a destruction would destroy — the trail is its home, and no column can carry it without duplicating the act.
The tie-breaker, learned from reader 1 (added §7, 2026-08-21). Where a question can be phrased both ways, two things decide it. First, WHAT USES THE ANSWER: if it is an authorization decision, the trail is never the source — an evidence store that returns "nothing found" hands an authorization gate a PERMIT, so every reachability concern (RLS, retention, an index regression) becomes a silent fail-open. Second, WHETHER THE ANSWER CAN EXPIRE: an act is immutable and its record is forever, so a question whose true answer STOPS BEING TRUE — as "who submitted the open round" stops the moment the round closes — is not a question about the act at all. It is a question about the thing's current state that merely happens to have an act in its history, and only the thing's row can stop answering it. That combination is what moved reader 1 from admitted to condemned within a day.
Applied to §2, the test is not vacuous: it admits readers 3–5 and 7–9 with an argument each, and it condemns two — reader 6, which already condemns itself in its own KDoc, and reader 1 (with its sibling 2), which this amendment first admitted and §7 corrects.
5. Enforcement — stated honestly¶
Today this rule is enforced by review convention only, and the enumeration above is what review
convention is worth: two declarations skipped, one authorization-grade reader unseen for five weeks,
and the one gate anybody had (grep from(AUDIT_EVENT)) blind to the path four of six readers take.
No structural gate is built by this amendment. The shape one would take is named so it is not
re-invented: an architecture test that treats ch.lqms.audit imports outside backend/audit —
not just AUDIT_EVENT references — as the watched set, holding an allow-list of reader sites with
the purpose each was admitted under, so adding a reader means editing the list where this ADR is
cited. Registered, not decided.
6. What this amendment does NOT decide¶
- ~~Whether four-eyes should stop reading the trail.~~ DECIDED — 2026-08-21, hours later, by
Nicolas: "I'd prefer the promotion of the submitted_by column, even if this means we need to
rewrite the import." He also ruled the semantics: PER-ROUND — the column names the submitter of
the CURRENT round, not the last submitter ever. Realized by V107; see §7. (The import needed no
rewrite: the ADR-0113 arrival importer provably cannot produce an
IN_REVIEWversion, so imported versions carry a null submitter honestly.) revoked_by/revoked_at(reader 6's fix), already registered as its own migration.- The structural gate (§5).
Decider: Nicolas Burri, 2026-08-21 (fix-log decision 5, option (a): "amend it to name the legitimate readers and restate the rule"). Written by the amendment agent, which also reported the one place it went beyond the ruling: §4 restates the rule on purpose rather than on module boundary, which legitimized reader 1 — a runtime read that §3 as written forbids — and that widening was flagged for his confirmation, with the promotion alternative registered above.
That flag is resolved, and in the stricter direction: he took the promotion, so §4 no longer has to admit reader 1 at all. The widening still stands as the rule's shape (purpose, not module), but it now costs nothing — the one reader it was straining to accommodate no longer exists. See §7.
Amendment §7, 2026-08-21 (same day) — the four-eyes reader is gone; the rule got stricter, not wider¶
The amendment above was written in the morning and flagged one widening for Nicolas: §4's purpose-based restatement admitted reader 1, the four-eyes trail read, which §3 as written forbade. He answered by removing the reader instead of blessing it — "I'd prefer the promotion of the submitted_by column" — and ruled its semantics PER-ROUND. V107 realizes that, in V088's shape.
What changed in the tree¶
document_versiongainssubmitted_by/submitted_at— the submitter of the round CURRENTLY OPEN. Written bysubmitin the transaction that opens the round; CLEARED by all four transitions that end one (reject, withdraw, release, cancel). Backfilled once from the latestSUBMITTEDper currently-IN_REVIEW version, inlatestActorFor's own ordering, so the handover is behaviour- preserving for existing data.WorkflowActionEvaluator.submitterUnderFourEyes(reader 1) reads that column and became pure — noDSLContext, no query.TaskService.blockedSubmitterVersions(reader 2) reads it from the row its page query already fetches. Both audit-repository calls are gone, and with them the last runtime trail read outside the audit module that fed an authorization decision.- Readers of the trail outside
backend/auditdrop from six to four (3, 4, 5, 6). Of those, only reader 6 is still condemned, and itsrevoked_by/revoked_atpromotion remains registered.Amendment (2026-09-06, row 63 built): the promotion landed — V131 stamps
revoked_by/revoked_atas real columns (backfilled to reproduce the retired read's exact latest-wins ordering), the condemned read is deleted, and no runtime trail read outsidebackend/auditfeeds an authorization or projection decision any more. The V107-era dead helpers (latestActorFor/latestActorsFor) are flagged for removal on the next touch.
What it teaches this ADR¶
The morning's enumeration found the rule too narrow and widened it to fit reality. The afternoon's ruling shows the other move was available: change the reality. A rule that has to stretch to admit its most dangerous reader is telling you about the reader, not about the rule — and the cost of the promotion (one migration, two call sites, fewer queries) was smaller than the cost of the exception it replaced. Where the two are close, prefer the promotion; the trail read is the one that cannot be made safe later.
The residual is closed too (ruled the same day). The first cut of V107 left one: an IN_REVIEW row
with a null submitted_by bars nobody, exactly as an unreadable SUBMITTED event barred nobody before —
four-eyes silently switched off for that round. Nicolas ruled it closed and explicitly accepted flattening
deployed systems if that was the price. It was not: V107's constraints are now a biconditional
(..._present_when_open + ..._absent_unless_open, two named constraints so a violation says which half
it broke), the columns stay nullable because DRAFT/RELEASED/CANCELLED rows legitimately carry no stamp,
and a pre-constraint guard fails the migration, naming the offending version ids, rather than skipping
a row it cannot backfill or inventing a submitter for it. A version that reached review with no submit act
is a defect worth seeing, and there is no honest value to invent for an authorization control.
Every producer of IN_REVIEW was verified rather than assumed: markInReview is the state's only writer,
LifecycleService.submit its only caller, the REST submit endpoint its only entry — and the two non-REST
producers (deploy/seed-demo.sh's deliberately-in-review demo SOP, scripts/import/load.py's release
pass) both go through that endpoint. The three test fixtures that forged IN_REVIEW rows now stamp them,
which is the honest fix: a fixture that fakes a state should fake a state the lifecycle can actually
produce. So four-eyes can no longer fail open — that is now a schema property, not a code convention.
Decider: Nicolas Burri, 2026-08-21 ("I'd prefer the promotion of the submitted_by column, even if this means we need to rewrite the import"; and, on semantics, per-round). Realized by V107.