Change history¶
AuditedModel records who last touched a row. karni.audit records what
changed: for a model that opts in, every create, update, delete and restore
leaves a ChangeLog row with the actor, the timestamp, the action and a diff
of the changed fields only. No project writes its own history table, signal
handlers or history screen (ADR 0002, P4).
Opt in on the model¶
class Price(BaseModel):
amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=3)
note = models.TextField(blank=True)
api_token = models.CharField(max_length=64, blank=True)
class Karni:
audit = True # every field (or a list: ["amount", "currency"])
sensitive = ["note"] # recorded as changed, never its value
Logging every row of every table is a storage problem, so nothing is audited
until a model says so. audit = True covers every concrete field except the
primary key and the two timestamps that move on every save; a list names the
fields exactly. A name the model does not have is refused at boot (E007).
What is captured, and where from¶
Capture happens at the model layer — a pre_save snapshot of the audited
columns, then post_save / post_delete — so a change made through the API,
the admin, a management command or the shell leaves the same entry. The
actor is the current user (CurrentUserMiddleware in a request,
karni.middleware.current_user(user) in tasks and commands, nobody in a bare
shell). The diff is {field: [old, new]} for changed fields only; a save
that changes nothing records nothing. Relations are recorded by primary key,
decimals and dates as strings, files by name. A soft delete is delete, an
undelete is restore, a hard delete is delete with every value → null.
Redaction. Fields named in Karni.sensitive, and any field whose name
contains password, token, secret or key (KARNI_AUDIT_REDACT), are
recorded as ["[redacted]", "[redacted]"]: the entry says the field
changed, never what it was.
Not captured, by design: QuerySet.update() and bulk_update(). They
bypass Django's signals and leave no entry. karni check flags both on an
audited model (audited-bulk-write, rule K113); save the instances
instead, or opt out on the line with
# karni: allow(audited-bulk-write): <reason> when a backfill really needs
it. raw() and SQL-level writes are likewise invisible.
Cost. For an audited model a save costs one extra SELECT of the audited
columns before the write and one INSERT after it; the suite asserts the
overhead stays under 5 ms per save against an unaudited model on SQLite
(tests/test_audit.py::test_capture_cost_per_save_is_small; measured well
under 1 ms on the development machine). For a very wide model, or one saved
in tight loops, set KARNI_AUDIT_ASYNC = True: the entry is written by a
Celery task after the transaction commits, and history becomes eventually
consistent — an entry may lag the row by the queue's latency.
Where it shows¶
- Admin. The History tab of an audited model's change page lists the
field changes (date, user, action, each field's old → new) above Django's
own admin-action log. The
Change logadmin lists every entry, read-only. -
API.
GET /api/<resource>/<id>/history/on everyBaseViewSet, paginated, newest first:{"id": 12, "timestamp": "2026-10-19T10:00:00Z", "actor": {"id": "…", "email": "ada@example.com"}, "action": "update", "diff": {"amount": ["10.00", "12.50"]}, "fields_changed": ["amount"]}The row is looked up through the viewset's read scope, so an unreadable row is a 404; a model that does not opt in answers 404 saying so, and the OpenAPI document carries the operation only for audited resources. -
@karni/ui.<ChangeHistory resource="prices" id={id} />renders the list (useChangeHistory(resource, id)is the hook behind it).
Visibility. History respects field-level visibility:
a field Karni.field_roles hides from a role is absent from every diff that
role sees, in the admin and the API alike.
Retention¶
Nothing is pruned unless you ask: KARNI_AUDIT_RETENTION_DAYS = 90 schedules
karni.audit.tasks.prune_change_logs daily (declared in
karni/audit/schedules.py; it is a no-op while the setting is None).
Projects with a legal retention requirement archive before pruning: the task
is a plain function — call prune_change_logs(days=365) from your own task
after exporting ChangeLog.objects.filter(timestamp__lt=cutoff) to cold
storage, or point CELERY_BEAT_SCHEDULE["karni-prune-change-logs"] at a
wrapper that does both.
Settings: KARNI_AUDIT_ASYNC, KARNI_AUDIT_RETENTION_DAYS,
KARNI_AUDIT_REDACT (settings).
Batches¶
An operation that changes many rows at once — an import
today — is recorded as one ChangeBatch (who, when, what kind, a label such
as the file name, and what it knew about itself in meta), and every entry
written while it ran points at it through ChangeLog.batch, so "which rows
did this import touch" and "which import changed this row" are one query
each. In code, with karni.audit.capture.change_batch("import", label) as
batch: groups whatever the block records; the history endpoint and
<ChangeHistory/> carry the batch id on each entry.