Skip to content
Alvo is pre-v0.1: the image runs from its edge tag, and no NuGet package or release is published yet.Pre-v0.1: no release yet.Roadmap and status

Audit row changes

Record on every row who created it, who last changed it and when, with values no caller can forge.

  • The stack from Run your own descriptor, run from its alvo-help-desk directory with COMPOSE_FILE and that page’s secrets exported in this shell.
  • Two keys: agent (roles agent, authenticated) and admin (roles admin, authenticated). Each key’s User is 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.

"audit": true on an entity adds four columns, and Alvo fills them on every write: created_at, created_by, updated_at and updated_by.

  1. Start the stack over this page’s descriptor. The first command deletes the stack’s database:

    Terminal
    docker compose down --volumes
    curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/audit-row-changes/01-audit.alvo.json
    docker compose up --wait --wait-timeout 90
  2. 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.

  3. 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 Created
    Content-Type: application/json; charset=utf-8
    ETag: "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_at is the row’s version, and only an audited entity has one.

  1. The administrator closes the ticket, sending the version it read in If-Match. An update stamps updated_at and updated_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 OK
    Content-Type: application/json; charset=utf-8
    ETag: "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"
    }
  2. The agent tries to update the ticket from the copy it read before the administrator’s change. Its If-Match names 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 Failed
    Content-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-Match in full.

  3. 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 Forbidden
    Content-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."
    }
WriteStampedNot touched
createcreated_at, created_by, updated_at, updated_by—
updateupdated_at, updated_bycreated_at, created_by
deletenothing: 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_by in a rule: created_by == @user.id is the ownership pattern in Access rules.

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.

    The dashboard's Configuration history screen for the bike-workshop project: one revision, r1 Initial descriptor, and a note that an audit trail of the data does not exist yet.The dashboard's Configuration history screen for the bike-workshop project: one revision, r1 Initial descriptor, and a note that an audit trail of the data does not exist yet.
  • A change log. Every write’s event carries the row before and after (data.old_record and data.record). To keep a full history, send those events to a store of your own with an after-hook webhook.

StatusProblem typeWhenFixReturned by
403forbiddenThe body names an audit column.Remove it; Alvo writes those columns.every host
412precondition-failedIf-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

Read data: filter, sort, page: query rows with filters, ordering and keyset pages.