Context
ADR-017 chose CQRS-lite over event sourcing, and the audit log is what makes that trade honest: without a complete change record, rejecting event sourcing would mean giving up the ability to answer “why is this row like this”. It has a second job. Ley 21.719 audits operational evidence, and the audit log is the evidence: who accessed a national ID, when a consent changed, which operator impersonated which tenant. That is why access reads are logged and not only writes. It is delivered early because it has no dependencies and everything depends on it. A command handler written before the audit log exists will be written without it.Scope (normative)
core.audit_log, append-only, monthly RANGE partitioned.- Typed actor:
user | member | api_key | system, with id. - Full old→new diff of the changed entity.
correlation_idpropagation from the request that caused it.- The helper every command handler calls, inside the command transaction.
- Read endpoints for the tenant’s own trail and for Softcrum Ops.
- Retention per plan, with the legal minimum applying independently.
Non-scope (normative)
- Application logging and traces — that is
packages/observability, a different concern with a different retention and a different destination. - Alerting on audit patterns — a later security feature.
- Immutability enforced by cryptography. Append-only plus restricted grants is the F1 posture; a hash chain is a possible later hardening.
Behaviour (normative)
- Every command writes exactly one audit row inside its own transaction. If the command rolls back, the audit row rolls back with it — an audit entry for something that did not happen is worse than none.
- The actor is always typed and always present.
systemis a valid actor; unknown is not. - The diff is
old→newover the changed fields, complete (DEC-C2). Sensitive values are recorded masked: the log proves the change happened without becoming a second copy of the national ID. correlation_idis mandatory and inherited from the originating request, so an audit row joins to the events, jobs and sends it caused.- The table is append-only. No update path, no delete path, and the application role holds no grant for either.
- Reads that expose sensitive data write their own audit row — reading a full national ID is an auditable act (DEC-A4).
- Partitioned monthly on
occurred_at; every query carries the partition-key predicate. - Retention follows the plan tier, and the legal minimum applies independently and wins when it is longer.
- A tenant can read its own trail, including operator impersonation performed against it. Hiding that would defeat the purpose of logging it.
Data (normative)
API (normative)
The time range is required, not defaulted — it is the partition key, and an unbounded audit
query is a full scan of the largest table in the schema.
Events (normative)
None. The audit log is a consumer of everything and a producer of nothing; emitting an event about having audited something would be a loop with no consumer.Acceptance criteria (normative)
- A command that rolls back leaves no audit row.
- Every command handler in the codebase writes an audit row — enforced by a lint rule, not by review.
- The diff of an update contains exactly the changed fields, with old and new values.
- A national ID appearing in a diff is masked.
- Reading a full national ID produces an audit row naming the actor and the contact.
- An audit query without a time range is rejected with a typed error.
- Update and delete attempts fail at the database grant level, not only in application code.
- Negative: no audit row exists with an untyped or absent actor.
Execution
Single slice, synchronous command. Schema and partitions in TS-001; the handler helper inpackages/core so every module writes the same shape.