Court-grade audit trail — architecture & legal basis
Status: Proposed — 2026-08-25 (revised after an independent review). Decides the audit-trail architecture. Full event sourcing is considered and deferred. Legal notes here are engineering input, not legal advice — a Portuguese lawyer confirms the flagged items.
Honesty caveat on "court-grade": what this ships is tamper-evident against outsiders and after-the-fact single-row edits. It is not resistant to a privileged insider (an admin/DBA with DB+app control can rewrite every row and recompute every hash into a clean chain; the append-only triggers are DDL a superuser can drop). That threat is real here — the operator is often the employer running the process (a structural conflict of interest). Insider-resistance arrives only with external anchoring of the chain head (see below). Until an anchor lands, do not market this as insider-proof.
Context
The audit trail is the product's spine — proving who did what, when. The PoC autopsy set the bar:
server-authored, awaited, unconditional, paginated, append-only, tamper-evident, typed vocabulary.
#44 ships the hash-chained, append-only, per-stream ledger (canonical serialization, advisory-lock
append, VerifyChain, DB-enforced immutability, transactional commit) this builds on.
What the law requires (research summary)
- Admissibility is met by an internal log — under free appraisal (livre apreciação, 607.º/5 CPC). QES/QTSP are not required for the log to be evidence. (Note: free appraisal cuts both ways — it imports no presumption, so the judge may also assign little weight.)
- Weight is a threat-model gradient. Internal hash chain → outsiders. External anchoring of the head (KMS-signed → ideally a qualified timestamp / QTSP) → the privileged insider, and a QTSP imports an eIDAS presumption (burden flips to the challenger). Per-row QES is wrong (rows are system events → a qualified seal is the right primitive; QES belongs on human deliverables).
- Semantic capture beats mechanical — and decisively, a data-diff cannot capture reads (a Registo de Acesso changes no row), which the law requires.
The architecture
One write path; per-case/per-tenant streams. Every audited event flows through the same in-transaction dispatch → hash-chained ledger append — but into a per-case (or per-tenant) stream, not one global stream. This is required, not cosmetic:
- Concurrency: a single global
pg_advisory_xact_lockwould serialize all audited writes. - Verification/scale:
VerifyChainloads a whole stream into memory; a global stream grows unbounded. - Court production (the decisive reason): the export unit is one case's stream, self-verifiable in
isolation. A filtered excerpt of a global chain doesn't verify (chains only verify contiguous
Seq); handing over the whole global chain would leak every other case/tenant. So per-case streams are what make a verifiable, confidential court export possible.
Drop "one canonical stream" framing; keep one write path.
Event sources (three), all server-authored (actor from the authenticated identity, never client-supplied):
- Semantic domain events — the case workflow (
ChargesIssued, …), the strong records a court reads. Also promote legal-authority acts to semantic events: role/membership changes and the mandatário/advogado registry ("who authorized whom to act") are who/did/what, not mechanical CRUD. - Explicit semantic events for auditable non-writes a diff can't see — logins, the Registo de Acesso (consulting the process), document custody (append with the file's hash).
- Mechanical
*Changedevents for the remaining CRUD entities — FROZEN, see below.
FROZEN: the mechanical CRUD-capture path (re-spec before building)
An EF SaveChanges interceptor that synthesizes diff events was reviewed and paused — it reinvents
a transactional outbox with three real hazards: re-entrancy (the ledger's own SaveChanges
re-fires the interceptor mid-dispatch), DI-scope bleed (a singleton interceptor holding a
request-scoped buffer → one request's events land in another's stream), and undefined ordering
(Seq is the legal timeline).
What we learned from Audit.NET (Audit.EntityFramework):
- It solves re-entrancy cleanly — an
AuditDisabledsuppress-flag during the audit write + entity exclusion. Adopt this pattern. Its diff-extraction (OriginalValuesvsCurrentValues) is also reusable. - It does not solve atomicity: by default it writes the audit after the business
SaveChanges, outside the transaction — which would violate #44's "no committed change lacks its entry." It has no durable outbox; same-transaction requires opting into its EF data provider. OurUnitOfWork(dispatch inside the transaction) is a better atomicity base than Audit.NET's default. - Its interceptor keeps state in instance fields, safe only if registered per-DbContext-instance (a footgun if registered singleton).
Re-spec requirements when unfrozen: capture the diff at SavingChanges with a suppress-flag +
audit-infra exclusion; stash in a request-scoped buffer; emit through our existing in-transaction
dispatch (not Audit.NET's write model); deterministic Seq order (stable raise order,
semantic-before-mechanical); and a DB-level completeness backstop (see next).
Completeness — scoped honestly
The change tracker cannot see ExecuteUpdate/ExecuteDelete, raw SQL, ON DELETE CASCADE, or
migrations — so a change-tracker audit is not "no silent gaps." Therefore:
- The audit is complete for guarded-aggregate actions (no public setters; every mutation raises an event) and explicit actions — this is structural, and it's the legally-central surface.
- For the CRUD/raw-SQL surface, either add a DB-level backstop (pgaudit / audit triggers / logical CDC) or state plainly that those paths are out of the tamper-evident audit. The mutation-without-event guardrail must fail the commit in production (not just warn) if we want to claim completeness.
PII / GDPR — keep it out of immutable payloads
Serializing full events (and old→new field values) into an append-only, hash-chained, trigger-locked
table makes right-to-erasure impossible without breaking the chain. So do not store PII by value
in payloads: use per-subject encryption (crypto-shredding — delete the key to erase) or
store-by-reference (pin a hash/id; the erasable data lives in a mutable, access-controlled table).
This mirrors the document model (pin the hash, bytes live elsewhere) — apply the same discipline to
event payloads. This is an architecture requirement, not just a lawyer question.
Documents
Integrity = content-hash pinning at submission (the git model): the bytes live in relational/blob storage; the hash-chained entry pins their SHA-256. Verify = rehash + compare + verify chain. Separately, QES on genuine human deliverables (the decision) earns wet-ink equivalence (art. 376.º CC) — a distinct signing feature, not the audit log.
External anchoring — interim (ours) vs qualified (PM)
- Interim (in-scope-lite, our code): a scheduled job anchors the head — a KMS/HSM-signed head and/or a free RFC 3161 timestamp to an external sink. No legal presumption, but it closes the insider gap technically and removes the "silently fabricable" hole. Leave a clean seam for it.
- Qualified (PM / procurement): a QTSP qualified timestamp on the head → the eIDAS legal presumption. Business decision; raised with the client.
Considered & deferred
- Full event sourcing — the requirements (integrity, custody, complete audit) are met by
audit-log + relational + content-hashing; ES buys no tamper-evidence over that (an ES event
referencing a mutable relational entity extends no trust unless it pins the entity's hash — which the
audit log already does). ES's value is operational state derivation, not required. Deferred
as a separate architecture decision. (An earlier
EventSourcedAggregateprimitive was explored on this branch and then removed as dead scaffolding; if ES is pursued, it starts fresh from that decision.) - Unified single ES+audit store — rejected for now (audit-log + relational is simpler, sufficient).
- JSONB diff as the primary audit — rejected (mechanical; can't capture reads; needs expert testimony). Used only as embedded metadata inside events where field detail matters.
Open items — a Portuguese lawyer must confirm
- Persuasiveness of a strong non-qualified log under livre apreciação — is anchoring/QTSP effectively expected?
- Whether a convenção de prova in the ToS binds users to the trail (limits vs third parties).
- Which deliverables legally require a QES vs advanced signature + qualified timestamp.
- Whether a qualified seal on checkpoints/exports is accepted for a system-generated log.
- Sector retention rules; GDPR retention/minimisation on the log's own data.
- Court-production formalities (expert certification, export form, hash-verification protocol).
References
eIDAS Reg. (EU) 910/2014 (Arts. 25, 35, 41); DL 12/2021 (art. 3); Código Civil art. 376.º; ISO/IEC
27037; RFC 3161. Internal: #44 (the ledger this builds on), PRD-004, api-versioned-responses.