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

Before-hooks

Validate and transform writes: refuse a write, or fill in a value, inside the write's own transaction.

  • 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. This page uses the agent key (roles agent, authenticated).
  • Access rules for the entity: they decide who may write at all, and a hook never widens them.

The responses below were captured from a real host when the site was built, so your ids will differ.

An entity’s hooks hold lists under beforeCreate, beforeUpdate and beforeDelete. Each entry is an optional condition and one action. A reject action cancels the write when its condition is true, and its text, a plain string, becomes the problem document’s detail: write it for the person who will read it.

  1. Start the stack over this page’s first 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/before-hooks/01-reject.alvo.json
    docker compose up --wait --wait-timeout 90
  2. This descriptor refuses to close a ticket that has no resolution:

    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" },
    "resolution": { "type": "text" }
    },
    "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"
    },
    "hooks": {
    "beforeUpdate": [
    {
    "condition": "new.status == 'closed' && !has(new.resolution)",
    "action": { "reject": "Close a ticket with a resolution: say how it was solved." }
    }
    ]
    }
    }
    }
    }
  3. Create a ticket, then try to close it without saying how it was solved:

    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: "639273155174404380"
    Location: /api/tickets/b2ccd14e-83d5-47da-8fb9-5b866e48c21b
    {
    "id": "b2ccd14e-83d5-47da-8fb9-5b866e48c21b",
    "title": "Printer on fire",
    "status": "open",
    "resolution": null
    }
    Request
    curl -sS -X PATCH http://localhost:8080/api/tickets/b2ccd14e-83d5-47da-8fb9-5b866e48c21b \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
    -H "Content-Type: application/json" \
    -d '{"status":"closed"}'

    403 Forbidden

    Response
    HTTP/1.1 403 Forbidden
    Content-Type: application/problem+json
    {
    "type": "https://alvo.dev/errors/forbidden",
    "title": "Forbidden",
    "status": 403,
    "detail": "Close a ticket with a resolution: say how it was solved. (refused by the before-hook at '/entities/tickets/hooks/beforeUpdate/0')"
    }

    The detail ends with the hook’s JSON pointer, so you can find the hook that refused.

  4. With a resolution, the same update goes through:

    Request
    curl -sS -X PATCH http://localhost:8080/api/tickets/b2ccd14e-83d5-47da-8fb9-5b866e48c21b \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
    -H "Content-Type: application/json" \
    -d '{"status":"closed","resolution":"Replaced the fuser unit."}'

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    ETag: "639273155174842360"
    {
    "id": "b2ccd14e-83d5-47da-8fb9-5b866e48c21b",
    "status": "closed",
    "resolution": "Replaced the fuser unit.",
    "updated_at": "2026-10-11T11:38:37.484236+00:00"
    }

A condition over a missing value does not fire. A comparison with a null operand is false, and a function called with a null argument returns null, so size(new.resolution) == 0 would let a ticket with no resolution through. Test presence with has(new.resolution), as above.

A mutate action sets fields of the row about to be written. Each value is a JSON literal or {"$cel": "…"}, an expression over new, the row as it will be stored.

  1. On create, trim the title, derive a slug from it and round the estimate to whole hours. On update, stamp closed_at when the status changes to closed:

    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" },
    "resolution": { "type": "text" },
    "slug": { "type": "string", "maxLength": 40 },
    "estimate_hours": { "type": "decimal", "precision": 5, "scale": 1 },
    "closed_at": { "type": "datetime" }
    },
    "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"
    },
    "hooks": {
    "beforeCreate": [
    {
    "action": {
    "mutate": {
    "title": { "$cel": "trim(new.title)" },
    "slug": { "$cel": "lowerAscii(replace(trim(new.title), ' ', '-'))" },
    "estimate_hours": { "$cel": "math.round(new.estimate_hours)" }
    }
    }
    }
    ],
    "beforeUpdate": [
    {
    "condition": "new.status == 'closed' && !has(new.resolution)",
    "action": { "reject": "Close a ticket with a resolution: say how it was solved." }
    },
    {
    "condition": "changed(status) && new.status == 'closed'",
    "action": { "mutate": { "closed_at": { "$cel": "now()" } } }
    }
    ]
    }
    }
    }
    }
  2. Apply it by recreating the alvo container. Adding fields discards nothing, so the restart applies it (Apply and evolve your descriptor covers the other ways):

    Terminal
    curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/before-hooks/02-mutate.alvo.json
    docker compose up -d --wait --force-recreate alvo
  3. Create a ticket with untidy whitespace and an estimate of 2.5 hours:

    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 ","estimate_hours":2.5}'

    201 Created

    Response
    HTTP/1.1 201 Created
    Content-Type: application/json; charset=utf-8
    ETag: "639273155180048220"
    Location: /api/tickets/bbd71189-f7b8-4fda-83d0-6c25145a38f8
    {
    "id": "bbd71189-f7b8-4fda-83d0-6c25145a38f8",
    "title": "Printer on fire",
    "slug": "printer-on-fire",
    "estimate_hours": 3,
    "closed_at": null
    }
  4. Close it. The second beforeUpdate hook stamps the time:

    Request
    curl -sS -X PATCH http://localhost:8080/api/tickets/bbd71189-f7b8-4fda-83d0-6c25145a38f8 \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
    -H "Content-Type: application/json" \
    -d '{"status":"closed","resolution":"Replaced the fuser unit."}'

    200 OK

    Response
    HTTP/1.1 200 OK
    Content-Type: application/json; charset=utf-8
    ETag: "639273155180548440"
    {
    "id": "bbd71189-f7b8-4fda-83d0-6c25145a38f8",
    "status": "closed",
    "resolution": "Replaced the fuser unit.",
    "closed_at": "2026-10-11T11:38:38.054844+00:00"
    }

now() is the instant the write is stamped with, the same one its audit columns get. The slug is stamped once, when the ticket is created, so it stays stable when the title changes later; a value that must always follow other fields of the row belongs in a computed field instead.

A value a hook writes is checked against its field exactly like a value a caller sends: maxLength, enum values, format, required, decimal precision and scale. A long title makes a slug longer than its 40 characters:

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":"The third-floor printer prints every page twice since Monday"}'

403 Forbidden

Response
HTTP/1.1 403 Forbidden
Content-Type: application/problem+json
{
"type": "https://alvo.dev/errors/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "The before-hook at '/entities/tickets/hooks/beforeCreate/0' computed a value for 'slug' that breaks the 'max-length' facet the field declares: A value is longer than the 40 characters the field declares. Nothing was written."
}

The refusal is a 403 forbidden, not a 422 validation, because the caller did not send the field and cannot fix it by changing the body. It names the hook, the field and the facet, never the value; for a field that is hidden, statically or for some role, it names the hook only, so the refusal does not reveal the field. To make the value fit, cut it: substring(x, 0, math.least(size(x), 40)), where x is the slug expression. A substring past the end of the text fails the write, which is why the cut needs math.least.

Before-hooks run inside the write’s own transaction, after the caller’s body is checked and before the row is stored: on update and delete over the locked row as it was, so old. is exactly what will be replaced. After a mutate, the create or update rule is checked again over the changed row, so a hook can never place a row the rules refuse; a hook may, however, set a field that is read-only for callers. A refusal rolls everything back: no row, no event. A hook has no network access and no clock budget; the work of a descriptor’s expressions is bounded by the language, which has no loops. A custom function an embedded host registers is host code and not bounded that way. CEL in Alvo explains the profiles.

What a condition can read, per hook point:

Hook pointnew.<field>old.<field>changed(<field>)mutate
beforeCreateyesnonoyes
beforeUpdateyesyesyesyes
beforeDeletenoyesnono, refused at apply

A condition may also test the caller, as in 'admin' in @user.roles, and compare, combine and do arithmetic. A mutate value may not read @user or @tenant and has no comparison: let the condition compare, and the mutate write a literal.

Built-in functions. Both a condition and a mutate value may call the built-ins, nested as deep as you need: text (trim, lowerAscii, upperAscii, replace, substring, size, startsWith, endsWith, contains), numbers (math.round, math.floor, math.ceil, math.abs, math.least, math.greatest, int), and conversions (string, timestamp). now() works in a mutate value only. Every signature is in the CEL function catalog, and an embedded host can add its own with Custom CEL functions. Text tests compare characters exactly: compare lowerAscii(new.title) to ignore case.

Order. Hooks run in the order they are declared, and each sees the row as the hooks before it left it, so a later hook’s condition can read an earlier hook’s value. Inside one mutate, every value is computed from the row as that hook received it. The facet check runs once, on the final row, and names the hook that last wrote the field.

Failing closed. A function that cannot answer refuses the write and rolls it back: int(new.code) over a text that is not a whole number, a substring past the end, a division by zero. A call over constants that always fails, such as timestamp('yesterday'), is refused when the descriptor is applied instead.

StatusProblem typeWhenFixReturned by
403forbiddenA reject fired (its text is the detail), or a mutate value breaks a facet of the field it writes.Send what the hook asks for; or make the hook’s value fit, for example by cutting it.every host
500function-failedA function failed while the write was evaluated: a built-in refused its input, or a custom function threw. Nothing was written.Fix the input the function reads, or guard the call with a condition.standalone; embedded only with AddAlvoProblemDetails()

A hook Alvo cannot compile is refused when the descriptor is applied, and the container does not come back: an old. reference in beforeCreate, a mutate in beforeDelete, an unknown field or function, @user in a mutate. The reason is at the end of docker compose logs alvo; in this build that refusal ends the process with exit code 139 instead of 78 (#340).

After-hooks, events and webhooks: react to a committed change, with a webhook or an email.