Before-hooks
Validate and transform writes: refuse a write, or fill in a value, inside the write's own transaction.
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. This page uses theagentkey (rolesagent,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.
1. Refuse a write
Section titled “1. Refuse a write”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.
-
Start the stack over this page’s first 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/before-hooks/01-reject.alvo.jsondocker compose up --wait --wait-timeout 90 -
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." }}]}}}} -
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 CreatedContent-Type: application/json; charset=utf-8ETag: "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 ForbiddenContent-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.
-
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 OKContent-Type: application/json; charset=utf-8ETag: "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.
2. Fill in values
Section titled “2. Fill in values”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.
-
On create, trim the title, derive a slug from it and round the estimate to whole hours. On update, stamp
closed_atwhen the status changes toclosed: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()" } } }}]}}}} -
Apply it by recreating the
alvocontainer. 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.jsondocker compose up -d --wait --force-recreate alvo -
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 CreatedContent-Type: application/json; charset=utf-8ETag: "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} -
Close it. The second
beforeUpdatehook 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 OKContent-Type: application/json; charset=utf-8ETag: "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.
3. A value must fit its field
Section titled “3. A value must fit its field”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:
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
HTTP/1.1 403 ForbiddenContent-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.
How it works
Section titled “How it works”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.
Options and variations
Section titled “Options and variations”What a condition can read, per hook point:
| Hook point | new.<field> | old.<field> | changed(<field>) | mutate |
|---|---|---|---|---|
beforeCreate | yes | no | no | yes |
beforeUpdate | yes | yes | yes | yes |
beforeDelete | no | yes | no | no, 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.
What can go wrong
Section titled “What can go wrong”| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 403 | forbidden | A 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 |
| 500 | function-failed | A 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).
Reference
Section titled “Reference”- Descriptor keys:
hooks,condition,reject,mutate. - Functions: the CEL function catalog.
- Problem types:
forbidden,function-failed. - Design notes: before-hooks
and the
Mutateprofile.
After-hooks, events and webhooks: react to a committed change, with a webhook or an email.