Audit row changes
Record on every row who created it, who last changed it and when, with values no caller can forge.
Before you start
Section titled “Before you start”- The stack from Run your own descriptor, run from its
alvo-help-deskdirectory withCOMPOSE_FILEand that page’s secrets exported in this shell. - Two keys:
agent(rolesagent,authenticated) andadmin(rolesadmin,authenticated). Each key’sUseris the id the audit columns record.
The responses below were captured from a real host when the site was built, so your ids and times will differ.
1. Turn on audit
Section titled “1. Turn on audit”"audit": true on an entity adds four columns, and Alvo fills them on every write: created_at, created_by,
updated_at and updated_by.
-
Start the stack over this page’s descriptor. The first command deletes the stack’s database:
Terminal docker compose down --volumescurl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/audit-row-changes/01-audit.alvo.jsondocker compose up --wait --wait-timeout 90 -
One line turns the trait on:
help-desk.alvo.json {"$schema": "https://alvo.dev/schema/v1/project.json","apiVersion": "alvo.dev/v1","name": "help-desk","auth": {"roles": ["admin", "agent"]},"entities": {"tickets": {"audit": true,"fields": {"title": { "type": "string", "required": true, "maxLength": 120 },"status": { "type": "enum", "values": ["open", "closed"], "default": "open" }},"rules": {"list": "'authenticated' in @user.roles","get": "'authenticated' in @user.roles","create": "'agent' in @user.roles || 'admin' in @user.roles","update": "'agent' in @user.roles || 'admin' in @user.roles"}}}}Do not declare the four columns yourself: on an audited entity those names are the framework’s, and declaring one is refused.
-
The agent files a ticket. A create stamps all four columns with the same instant and the same user:
Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"title":"Printer on fire"}'201 Created
Response HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8ETag: "639273155162211190"Location: /api/tickets/5065aa8d-386f-4756-9b0b-1aa16efaeca9{"id": "5065aa8d-386f-4756-9b0b-1aa16efaeca9","created_at": "2026-10-11T11:38:36.221119+00:00","created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01","status": "open","title": "Printer on fire","updated_at": "2026-10-11T11:38:36.221119+00:00","updated_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01"}The response also carries an
ETag:updated_atis the row’s version, and only an audited entity has one.
2. Record each change
Section titled “2. Record each change”-
The administrator closes the ticket, sending the version it read in
If-Match. An update stampsupdated_atandupdated_by, and leaves the creation record alone:Request curl -sS -X PATCH http://localhost:8080/api/tickets/5065aa8d-386f-4756-9b0b-1aa16efaeca9 \-H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \-H "If-Match: \"639273155162211190\"" \-H "Content-Type: application/json" \-d '{"status":"closed"}'200 OK
Response HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8ETag: "639273155162581760"{"id": "5065aa8d-386f-4756-9b0b-1aa16efaeca9","created_at": "2026-10-11T11:38:36.221119+00:00","created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01","status": "closed","title": "Printer on fire","updated_at": "2026-10-11T11:38:36.258176+00:00","updated_by": "8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02"} -
The agent tries to update the ticket from the copy it read before the administrator’s change. Its
If-Matchnames the old version, so nothing is written:Request curl -sS -X PATCH http://localhost:8080/api/tickets/5065aa8d-386f-4756-9b0b-1aa16efaeca9 \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \-H "If-Match: \"639273155162211190\"" \-H "Content-Type: application/json" \-d '{"title":"Printer on fire again"}'412 Precondition Failed
Response HTTP/1.1 412 Precondition FailedContent-Type: application/problem+json{"type": "https://alvo.dev/errors/precondition-failed","title": "Precondition Failed","status": 412,"detail": "The record was changed since the version this write carries. Re-read it and retry."}Read the row again and retry. Write data safely covers
If-Matchin full. -
A caller cannot write the audit columns, so a row cannot claim someone else created it:
Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"title":"Backdated","created_by":"8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02"}'403 Forbidden
Response HTTP/1.1 403 ForbiddenContent-Type: application/problem+json{"type": "https://alvo.dev/errors/forbidden","title": "Forbidden","status": 403,"detail": "Field 'created_by' is managed by the framework and cannot be written by a caller."}
What is recorded
Section titled “What is recorded”| Write | Stamped | Not touched |
|---|---|---|
| create | created_at, created_by, updated_at, updated_by | — |
| update | updated_at, updated_by | created_at, created_by |
| delete | nothing: the row is gone | — |
- The user is the id the caller’s key acts as. For an anonymous caller it is
null: there is no identity to record. - The instant is the write’s own, in UTC: the same one
now()returns in a before-hook and the same one the write’s event carries. - They are ordinary fields to read. Filter and sort by them like any other field
(Read data), and use
created_byin a rule:created_by == @user.idis the ownership pattern in Access rules.
What audit is not
Section titled “What audit is not”Audit records the latest change, not every change: the values a row held before are not kept. Two other things are easy to confuse with it.
-
Descriptor history. The dashboard’s Configuration history lists every revision of the descriptor: who changed what the backend is, when, and why. It records configuration, not rows. Rollback and revisions are in Apply and evolve your descriptor.


-
A change log. Every write’s event carries the row before and after (
data.old_recordanddata.record). To keep a full history, send those events to a store of your own with an after-hook webhook.
What can go wrong
Section titled “What can go wrong”| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 403 | forbidden | The body names an audit column. | Remove it; Alvo writes those columns. | every host |
| 412 | precondition-failed | If-Match names a version the row no longer has, or the entity has no audit and so no version. | Read the row again and retry with its ETag; turn on audit to use If-Match. | every host |
Reference
Section titled “Reference”- Descriptor keys:
audit,softDelete. - Problem types:
forbidden,precondition-failed. - Design note: the framework-managed columns.
Read data: filter, sort, page: query rows with filters, ordering and keyset pages.