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

Custom CEL functions

Register a function written in C# in your embedded host, and call it from your descriptor's hook conditions and mutate values.

  • The repository’s embedded sample, which registers this same function, from Embed Alvo in ASP.NET Core, run from a clone with the .NET 10 SDK, plus curl and jq. A custom function exists only in a host that registers it: the Docker image of Run your own descriptor knows the built-in functions only.
  • Before-hooks: a function is called from a hook’s condition or mutate.

Add it at the end of the AddAlvo chain with AddCelFunction(name, function, summary). The function can be a lambda, inline in Program.cs, and the summary is one sentence about it:

Program.cs
using MMLib.Alvo.Auth;
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<AlvoAuthOptions>(builder.Configuration.GetSection("Alvo:Auth"));
builder.Services.AddAlvo(alvo => alvo
.UseSqlite("Data Source=alvo.db")
.FromDescriptor("vehicles.alvo.json")
.AddDataApi(api => api.RoutePrefix = "/api/alvo")
.AddCelFunction(
"normalizeVin",
(string vin) => new string([.. vin.Where(char.IsAsciiLetterOrDigit).Select(char.ToUpperInvariant)]),
"Upper-cases a vehicle identification number and drops every character that is not a letter or a digit."));
var app = builder.Build();
app.MapAlvoHealth();
app.MapAlvoDataApi();
app.Run();

Alvo reads the signature off the delegate, lambda or method alike: each parameter’s name (vin) and type, and whether a parameter or the result may be null, from the nullable annotations. Everything is checked at the call, so a mistake is an ArgumentException while the host starts, never a surprise inside a write:

WhatAllowed
NameA lower-case ASCII letter, then letters, digits or _; at most 64 characters; not a built-in function (such as now), a CEL keyword or macro, or a reserved name such as has, changed, old, new or math.
ParametersAt most four, each string, long, int, decimal, bool, DateTimeOffset or Guid, or a nullable one of those.
ResultOne of the same types.
ShapeA synchronous delegate: no Task, ref, out or params, and not a multicast delegate.

The name and the summary are visible to everyone with Viewer access to the Management API, so put no secret or internal-only wording in them.

A hook’s condition and a before-hook’s mutate value call a custom function exactly like a built-in one. This copy of the vehicle-registry descriptor rewrites vin on every create:

vehicles.alvo.json
"hooks": {
"beforeCreate": [
{
"action": {
"mutate": {
"vin": {
"$cel": "normalizeVin(new.vin)"
}
}
}
}
]
}

It works in a condition too, such as "condition": "normalizeVin(new.vin) != new.vin". A rule, a computed field and the access block refuse it when the descriptor is applied, with the recipe in the refusal: store the value in a field with a mutate, then compare that field.

The shared examples/vehicle-registry/vehicles.alvo.json deliberately has no such hook. The Docker image serves the same file, and a hook calling normalizeVin would make the image refuse it as calling an unknown function. A descriptor that calls a custom function only runs in hosts that register it.

Start the sample over the descriptor with the hook, in the background and over a fresh database:

Terminal (in the clone)
export FLEET_DESK_KEY_SECRET="$(openssl rand -hex 16)"
Alvo__Auth__DevKeys__0__Secret="$FLEET_DESK_KEY_SECRET" \
dotnet run --project samples/MMLib.Alvo.Samples.EmbeddedHost -- \
--FleetDesk:DescriptorPath "$PWD/website/src/snippets/custom-cel-functions/host-only/vehicles.alvo.json" \
--FleetDesk:DatabasePath "$(mktemp -d)/fleet-desk.db" &
until curl -sf localhost:5199/health/ready > /dev/null; do sleep 1; done

Create an owner, then two vehicles: one with a lower-case VIN, one with dashes in it:

Terminal (in the clone)
KEY="agent.$FLEET_DESK_KEY_SECRET"
OWNER=$(curl -s -X POST localhost:5199/api/alvo/owners -H "X-Alvo-Api-Key: $KEY" \
-H "Content-Type: application/json" -d '{"name":"Fleet Desk Ltd"}' | jq -r .id)
curl -s -X POST localhost:5199/api/alvo/vehicles -H "X-Alvo-Api-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"vin":"1hgcm82633a004352","plate":"BA-777AB","make":"Skoda","model":"Fabia","year":2020,"owner_id":"'"$OWNER"'"}' \
| jq '{vin, plate}'
curl -s -X POST localhost:5199/api/alvo/vehicles -H "X-Alvo-Api-Key: $KEY" \
-H "Content-Type: application/json" \
-d '{"vin":"1hg-cm826-33a-004352","plate":"BA-778AB","make":"Skoda","model":"Fabia","year":2020,"owner_id":"'"$OWNER"'"}' \
| jq '{status, violations}'
kill %1

The first vehicle is stored with vin 1HGCM82633A004352: the row holds what the hook computed, not what the caller sent. The second is refused with a 422 validation whose violation is max-length on /vin. The caller’s body is checked against the field before any hook runs, so a function cannot rescue a value that is too long for its field. Whatever a hook writes is then checked against the same field, and a value that does not fit refuses the write with a 403 forbidden naming the hook and the field, never the value.

Every function a descriptor may call in a host is listed in that host’s function catalog, built-ins and yours alike: GET /management/projects/<project>/cel/functions over HTTP, or IAlvoManagement.GetCelFunctionsAsync in process. A custom function’s entry carries its signature, its summary, the provenance Host and the profiles Condition and Mutate. The sample maps no Management API, so it does not answer that route.

A host that mounts the admin dashboard offers the function in the hook editor, under Functions you can call here, with a this host badge where a built-in has built-in. The schema assistant learns the same list through its get_cel_functions tool. The built-ins are in the CEL function catalog.

Alvo cannot check these, and breaking one breaks writes, not only your function.

  • Pure. The same arguments give the same result, with no side effects. It runs inside the write’s transaction, and a write that rolls back must leave nothing behind. Sending mail or calling a service belongs in an after-hook.
  • Fast. It runs while the row’s locks are held, with no time budget and no CancellationToken. No network calls, no unbounded loops.
  • Thread-safe. One instance serves every request at once, and it cannot use a scoped service: it is a singleton closure.
  • Tenant-aware. Alvo’s tenant filter does not reach inside your code. A function that reads stored data must take the tenant as a parameter and filter by it: on a tenant-scoped entity, pass new.tenant_id, which a condition and a mutate can both read. @tenant.id works in a condition only.
  • Throw when you cannot answer; return null only when there is no value. A throw refuses the write and rolls it back. A null makes the call null, a condition over null does not fire, and a reject guarded by your function would let the bad input through.
  • No caller data in exceptions. What a function throws, message and stack trace, is logged at Error and never shown to the caller, so a value in the message ends up in every log sink you ship to.
  • A new meaning gets a new name. A descriptor stores names, not versions: register vatRate2 beside vatRate. Removing a function a stored descriptor still calls makes the next start refuse that descriptor.

The registration adds the function to the catalog the descriptor is compiled against, so an unknown name, a wrong number of arguments or a type mismatch is refused when the descriptor is applied, not when a write runs. Hook conditions and mutate values are evaluated in memory inside the write’s transaction, which is why they can call C#; rules and computed fields are compiled to SQL, which is why they cannot. CEL in Alvo explains the profiles.

StatusProblem typeWhenFixReturned by
500function-failedThe function threw, or a present argument does not fit its parameter (a fraction for an int, a text that is no Guid). Nothing was written; the detail names the function and never your exception’s text.Fix the input the function reads, or guard the call with a condition; the exception is in the host’s log.standalone; embedded only with AddAlvoProblemDetails()
403forbiddenThe value a mutate computed breaks a facet of its field, such as maxLength.Make the function’s result fit the field.every host
422validationThe caller’s own value breaks the field before the hook runs.Send a value that fits the field.every host

Without AddAlvoProblemDetails(), a function failure on a generated route reaches your app’s own error handling as an exception, and an in-process IAlvoData caller receives it as an Exception (Call Alvo from your endpoints).

Two failures stop the host instead. A registration Alvo cannot accept throws ArgumentException at the AddCelFunction call. A descriptor that calls a function the host does not register, with the wrong number of arguments, or from a rule or a computed field, is refused when it is applied: at start, the host does not start; through the Management API, the apply answers 422 validation.

Running in production: take a backend from a laptop to a server.