Skip to main content

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_lock would serialize all audited writes.
  • Verification/scale: VerifyChain loads 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):

  1. 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.
  2. 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).
  3. Mechanical *Changed events 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 AuditDisabled suppress-flag during the audit write + entity exclusion. Adopt this pattern. Its diff-extraction (OriginalValues vs CurrentValues) 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. Our UnitOfWork (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 EventSourcedAggregate primitive 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

  1. Persuasiveness of a strong non-qualified log under livre apreciação — is anchoring/QTSP effectively expected?
  2. Whether a convenção de prova in the ToS binds users to the trail (limits vs third parties).
  3. Which deliverables legally require a QES vs advanced signature + qualified timestamp.
  4. Whether a qualified seal on checkpoints/exports is accepted for a system-generated log.
  5. Sector retention rules; GDPR retention/minimisation on the log's own data.
  6. 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.