0025 — The audit log is a statutory record¶
- Status: accepted
- Date: 2026-08-03
- Supersedes: nothing. Extends 0001 and 0016.
Context¶
Work order F. Two things forced the shape of this, and neither is observability.
It is a statutory record, not a log. The Data Protection and Privacy Act, 2019 s.24(1)(c) gives a data subject the right to be told the identity of every third party who has accessed their data. s.16(4) requires that when a record is corrected, the parties who received the incorrect version are notified. Both questions are answerable only from here. That changes what a missing entry is: a gap in an application log is an inconvenience, and a gap in this one is a legally wrong answer to a subject's request.
0001 needed it. Append-only binds kernel_app, not the database. The schema
owner retains DELETE, and Postgres offers no way to revoke a right from a
table's owner durably. 0016 added a trigger that refuses live deletions for every
role — and conceded, in the migration itself, that an owner can drop the trigger
first. This log is what makes that concession survivable, because dropping a
trigger is a DDL event and DDL events land here.
Decision¶
A schema of its own, audit, with one table, audit.entry. Migration 0017.
Ids only, never bodies¶
Uuid arrays, bounded scalars, and one jsonb query descriptor with a hard size
cap. Nothing here can carry what a record said — only which record it was, who it
was about, who asked, and what the kernel decided.
An audit log full of personal data is a second copy of the thing it protects, sitting in a schema the application can write but cannot read. That is a worse exposure than the one it was built to detect, and it is the failure mode this kind of table reaches by accretion: one debugging session adds the request body "temporarily".
Four layers, because a rule that is only written down is not a control:
AuditDescriptorisRecord<string, string | number | boolean | null>. A record body is an object, so it is not representable. A call site cannot pass one without changing the type.AuditService.descriptor()re-parses at runtime and drops offending keys rather than stringifying them, recordingdescriptor_rejectedso the drop is visible. A truncated body is still a body.check (length(detail::text) <= 2048)in the database.- A test asserts
detailis the onlyjsonbcolumn in the schema.
INSERT only for the application¶
kernel_app holds INSERT on audit.entry and nothing else — no SELECT.
Reading the access log is a separate privileged path, because an application
that can read who has been looking at whom hands that answer to whoever
compromises it. AuditRepository has no read method and a test fails if one
appears.
A visible consequence: insert ... returning is unavailable, so the id is
generated in the application and the written row is returned from memory.
Append-only for everyone¶
A before update or delete trigger refuses both for every role including the
owner, on 0016's reasoning. There is no retention purge and none is planned:
this is evidence.
The database write is awaited and may fail the request¶
The opposite of how logging is normally treated, and deliberate. A disclosure
that cannot be recorded does not happen. This kernel fails closed elsewhere
for the same reason (see consent.service.ts), and a read that quietly proceeds
after its audit write failed is a read that cannot be answered for afterwards.
Ingest is idempotent per id, so a write that appends and then fails to audit is safe for the client to retry.
Shipping off-box is not awaited and cannot fail the request¶
AUDIT_SHIP_URL receives batches asynchronously. Every path out of
AuditShipper swallows its errors and counts them. A farmer's delivery does not
go unrecorded because a log collector in another data centre is down.
The queue is bounded at 10 000 and drops oldest-first, because an unbounded queue turns an unreachable collector into an out-of-memory kill — which loses every pending entry instead of the oldest few, and takes the kernel down with it.
Denials are recorded with their reason¶
Every ConsentDenied is written before it is thrown, with the ConsentReason
that produced it. Every RecordRejected is written with its code. This is the
half a success-only log never sees: a shifting denial rate is the earliest signal
that something upstream has broken, and it is only readable as a signal if the
reasons are distinguishable from one another.
The unauthenticated sync pull is called out specifically. It is the widest disclosure surface in the kernel, and an anonymous attempt against it is exactly the event worth having.
dataset on every entry¶
- A subject asking who has seen their data must not be handed a list of reads against fabricated records that merely resemble theirs.
DDL is captured¶
Two event triggers, ddl_command_end and sql_drop, writing schema.ddl
entries with the command tag, the object identity and current_user.
sql_drop filters to original or normal objects: a drop table cascades to
dozens of internal objects, and burying "somebody dropped the deletion guard"
under forty index entries defeats the purpose.
Where the audit point sits¶
At the call sites — ReadService.guard, SyncService.changes, and
IngestService.ingest — not inside ConsentService.
ConsentService.decide stays a pure function with no I/O, which is what makes it
testable and what its own file comment promises. The cost is that a new
disclosure path could forget to audit, so that is enforced by test rather than by
structure: test/invariants/audit.test.ts walks every file under src/, and
fails if one consults consent without also writing an entry, or if one calls
assertPermitted — which throws without auditing and is now used by nothing.
Consequences¶
ReaderandIngestContextcarry acorrelationId, set from the middleware.correlationOf()returns null rather than minting one, because an id created at the point of use correlates with nothing and only looks as though it does.- The migrator must be a superuser for
0017.create event triggeris superuser-only in PostgreSQL and there is no grantable privilege for it. The migration does not degrade gracefully: if the right is absent the deploy stops, which is correct, because a silently absent DDL capture is precisely the failure this is here to prevent. scripts/backup.shandscripts/restore.shverify the audit schema's constraints and grants, and deliberately not its row count — a restore is itself a long sequence of DDL, which this log records, so the count cannot match by construction. The duplication between the two scripts is retained for the reason given in their comments.- Every hand-assembled service in the test suite now takes a real
AuditService. Stubbing it would mean the whole suite exercises a path that never writes the column a subject's request is answered from.
Findings¶
- Un-shipped entries are lost if the process dies. They are not lost from
the database, which is the record of legal consequence, so a privileged
operator can reconcile from there. What is genuinely lost for that window is
the tamper evidence. Closing it properly means shipping from a reader
outside this process, which needs
SELECTonaudit.entry— and handing the applicationSELECTto buy that is a bad trade. A separate operator-role shipper would fix it and is not built. GRANTandREVOKEare recorded without an object identity. Postgres reports noobject_identityfor them inpg_event_trigger_ddl_commands(). The command tag and the role are captured; which grant changed is not. The backup manifest covers this from the other side by fingerprinting the grants themselves.- The statement text is not recorded. It would say which grant changed, and
it would also capture
create role ... password '...'verbatim. Not worth it. - Reading the log has no tooling. There is no endpoint and no script that
answers a s.24(1)(c) request; today it is a manual query as the owner. The
grants are right, the data is right, and the operator path is missing. That
is a deliberate scope boundary, not an oversight, but it means the statutory
obligation is currently met by a person with
psqlrather than by a process.