# Alvo documentation, full text > Describe your backend in one JSON file. Get a secure, production-shaped API — standalone in Docker or embedded in your ASP.NET Core app. Errors are RFC 9457 problem documents. Their `type` is `https://alvo.dev/errors/`; that domain does not resolve yet, so read `https://alvo.burgyn.online/reference/problem-types/#` instead. Branch on the slug, never on `detail`. --- # For coding agents Source: https://alvo.burgyn.online/start-here/coding-agents/ :::tip[What you will achieve] A coding agent that knows the descriptor format, edits a descriptor with the schema checking it, and applies a change to a running backend the safe way: check, preview, apply once. About ten minutes, starting from a stack serving your descriptor. ::: Alvo is built to be configured by an agent. The whole backend is one JSON file with a published schema, every refusal says what to fix, and every write to the configuration can be previewed and retried safely. ## Before you start - A stack serving your descriptor: [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). `curl` and `jq` for the shell recipe. - An API key whose roles reach a management level in the descriptor's `access` block. This page uses `admin` (roles `admin`, `authenticated`; secret `ALVO_ADMIN_KEY_SECRET`) and grants that role the `developer` level. ## 1. Point your agent at the docs Two plain-text files describe this site for a model: - `https://alvo.burgyn.online/llms.txt`: an index of every page, one line each. - `https://alvo.burgyn.online/llms-full.txt`: the pages themselves as Markdown, with every descriptor, request and captured response inline. Give your agent the first one in its instructions, and let it fetch the second when it needs the detail. ## 2. Give the editor the schema The descriptor format is a JSON Schema with a description on every key, served at `https://alvo.burgyn.online/schema/v1/project.json`. A descriptor's own `$schema` value, `https://alvo.dev/schema/v1/project.json`, does not resolve yet, so an editor does not find the schema from it. Map the file pattern to the served URL instead. In VS Code, add this to `.vscode/settings.json`: ```json { "json.schemas": [ { "fileMatch": ["*.alvo.json"], "url": "https://alvo.burgyn.online/schema/v1/project.json" } ] } ``` Every `*.alvo.json` file then gets completion, hover text and errors while you or your agent type. The same URL works in any editor that maps JSON files to a schema. The schema is also published key by key in the [descriptor reference](https://alvo.burgyn.online/reference/descriptor/). ## 3. Load the shared skills The dashboard's schema assistant learns the descriptor from nine skills, and they are plain Markdown your agent can load too. Each one teaches one part of the format and the mistakes Alvo refuses. **In Claude Code, install them as a plugin.** This repository is a plugin marketplace, and its `alvo` plugin carries the nine skills. In a session: ```txt /plugin marketplace add Burgyn/MMLib.Alvo /plugin install alvo@mmlib-alvo ``` Or from your shell: ```sh claude plugin marketplace add Burgyn/MMLib.Alvo && claude plugin install alvo@mmlib-alvo ``` Claude Code then loads a skill by itself when a task matches its description, such as adding a rule or a hook, and you can call one directly as `/alvo:alvo-descriptor-rules-and-cel`. `/plugin` updates the plugin like any other. **For another agent, or a folder of your choice**, a script downloads the same nine files, no clone needed: ```sh curl -fsSL https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/scripts/install-agent-skills | sh ``` It writes them to `.claude/skills/alvo-descriptor-*/SKILL.md` under the current directory and lists what it installed; any failed download stops it with an error. To put them elsewhere, such as your agent's own skills folder, pass the folder: `curl -fsSL https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/scripts/install-agent-skills | sh -s -- ~/.claude/skills`. Run it again to update them. To load them by hand instead, each skill is one file: - [Entities and fields](https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/plugins/alvo/skills/alvo-descriptor-entities-and-fields/SKILL.md) - [Field types and formats](https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/plugins/alvo/skills/alvo-descriptor-field-types-and-formats/SKILL.md) - [Rules and CEL](https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/plugins/alvo/skills/alvo-descriptor-rules-and-cel/SKILL.md) - [Hooks](https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/plugins/alvo/skills/alvo-descriptor-hooks/SKILL.md) - [Computed fields and rollups](https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/plugins/alvo/skills/alvo-descriptor-computed-and-rollups/SKILL.md) - [Indexes](https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/plugins/alvo/skills/alvo-descriptor-indexes/SKILL.md) - [Traits and tenancy](https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/plugins/alvo/skills/alvo-descriptor-traits-and-tenancy/SKILL.md) - [Project access](https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/plugins/alvo/skills/alvo-descriptor-project-access/SKILL.md) - [Capabilities and limits](https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/plugins/alvo/skills/alvo-descriptor-capabilities-and-limits/SKILL.md) In the repository they live in `plugins/alvo/skills/`. ## 4. Read errors by their type Every refusal, from the Data API and the Management API alike, is an RFC 9457 problem document. Its `type` is `https://alvo.dev/errors/`; branch on the slug, never on `detail`, which is prose. Those URIs do not resolve yet: the slug is the anchor on the [Problem types](https://alvo.burgyn.online/reference/problem-types/) page, so `https://alvo.dev/errors/forbidden` is documented at `/reference/problem-types/#forbidden`. A violation adds a JSON `pointer`, a stable `code` and a `fixSuggestion` an agent can act on. ## 5. Change a running backend The [Management API](https://alvo.burgyn.online/reference/management-api/) changes what the project is. Every route is closed to every caller except the bootstrap administrator until the descriptor's `access` block grants a level, so this descriptor, the help desk from the [Tutorial](https://alvo.burgyn.online/start-here/tutorial/), adds one: ```json "access": { "developer": "'admin' in @user.roles" } ``` Add it to your own descriptor and recreate the container before you call the routes below; without a level, every management route answers 403 `forbidden`. On the [Tutorial](https://alvo.burgyn.online/start-here/tutorial/)'s stack, in its `alvo-help-desk` directory, this downloads the descriptor with the block in place and applies it: ```sh curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/coding-agents/01-base.alvo.json docker compose up -d --wait --force-recreate alvo ``` The agent's change adds one field, `due_on`: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "providers": ["local"], "roles": ["admin", "agent"] }, "access": { "developer": "'admin' in @user.roles" }, "entities": { "tickets": { "audit": true, "fields": { "title": { "type": "string", "required": true, "maxLength": 120 }, "body": { "type": "text" }, "priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" }, "status": { "type": "enum", "values": ["open", "closed"], "default": "open" }, "estimate_hours": { "type": "decimal", "precision": 5, "scale": 1 }, "hourly_rate": { "type": "decimal", "precision": 6, "scale": 2 }, "estimate_cost": { "type": "decimal", "precision": 12, "scale": 2, "computed": "estimate_hours * hourly_rate" }, "due_on": { "type": "date" } }, "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", "delete": "'admin' in @user.roles" }, "hooks": { "beforeCreate": [ { "action": { "mutate": { "title": { "$cel": "trim(new.title)" } } } }, { "condition": "new.priority == 'high' && !has(new.body)", "action": { "reject": "A high-priority ticket needs a body." } } ] } } } } ``` 1. **Check an expression.** While it edits, the agent can ask what the apply would say about one expression, without applying anything. The body carries the working copy as `descriptorJson`, the JSON pointer of the slot (`path`, here `/entities/tickets/rules/update`) and the candidate (`source`). A typo in a role: ```http POST /management/projects/help-desk/cel/check HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET Content-Type: application/json ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "findings": [ { "path": "/entities/tickets/rules/update", "message": "'agnet' is not a declared role, so this membership test can never match.", "fixSuggestion": "Did you mean 'agent'? Declared roles: admin, agent, anon, authenticated.", "severity": 0 } ], "isValid": false } ``` An expression with a problem is a 200 with findings, not an error status. The corrected expression comes back clean: ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "findings": [], "isValid": true } ``` 2. **Preview the apply.** Read the current revision, then send the whole descriptor with `?dryRun=true`. The answer is the migration plan, and nothing is applied: ```http GET /management/projects/help-desk/descriptor HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "project": "help-desk", "revision": 1 } ``` ```http PUT /management/projects/help-desk/descriptor?dryRun=true HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET If-Match: "1" Content-Type: application/json ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "applied": false, "revision": 1, "plan": { "isEmpty": false, "hasDestructiveChanges": false, "steps": [ "AddField tickets.due_on" ] }, "replayed": false } ``` The body is `{"descriptorJson": ""}`. From a shell, `jq` builds it from the file, and the current revision goes into `If-Match`: ```sh curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/coding-agents/02-add-field.alvo.json REVISION="$(curl -sS localhost:8080/management/projects/help-desk/descriptor \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" | jq -r .revision)" jq -n --rawfile d help-desk.alvo.json '{descriptorJson: $d}' \ | curl -sS -X PUT "localhost:8080/management/projects/help-desk/descriptor?dryRun=true" \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "If-Match: \"$REVISION\"" \ -H "Content-Type: application/json" \ -d @- ``` Drop `?dryRun=true` and add an `Idempotency-Key` header to apply it for real, as the next step does. 3. **Apply it.** A write must say which revision it replaces, in `If-Match`. Without it, the API refuses rather than risk a lost update: ```http PUT /management/projects/help-desk/descriptor HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET Content-Type: application/json ``` ```http HTTP/1.1 428 Precondition Required Content-Type: application/problem+json { "type": "https://alvo.dev/errors/precondition-required", "title": "Precondition Required", "status": 428, "detail": "This write requires 'If-Match' carrying the descriptor's current revision, e.g. If-Match: \"3\". Read that revision from GET the same path. Applying without one is a lost update nothing would detect." } ``` With `If-Match` and an `Idempotency-Key`, the change is applied and the revision advances: ```http PUT /management/projects/help-desk/descriptor HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET If-Match: "1" Idempotency-Key: help-desk-add-due-on Content-Type: application/json ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "applied": true, "revision": 2, "plan": { "isEmpty": false, "hasDestructiveChanges": false, "steps": [ "AddField tickets.due_on" ] }, "replayed": false } ``` 4. **Retry safely.** If the response is lost and the agent sends the same request again, with the same key, the API does not apply it twice. It answers with the revision the first request appended, and `replayed: true` says this is that earlier result: ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "applied": true, "revision": 2, "plan": { "isEmpty": true, "hasDestructiveChanges": false, "steps": [] }, "replayed": true } ``` On a replay the plan is empty: it describes what this request did, which is nothing. Read what a revision applied from `GET …/revisions/{n}`. Rollback, destructive changes and revision history are in [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/). ## Access levels The `access` block maps the caller's roles to three levels; the highest one that matches wins. A descriptor without `access` admits nobody but the deployment's bootstrap administrator. | Level | May | |---|---| | `viewer` | read the descriptor, its revisions, the resolved schema, the capabilities and the CEL function catalog, and simulate a policy | | `developer` | everything a viewer may, plus check an expression, apply a descriptor and roll back, as long as the `access` block itself stays the same | | `admin` | everything, including a change to the `access` block, the AI connection and the people who may sign in | An API key's scopes limit only the Data API; on the Management API, a key reaches whatever its roles reach. The per-route table is in [`docs/architecture/management-api.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/management-api.md#the-surface). ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | The key's roles reach no level the route requires, or a `developer` changed the `access` block. | Grant the role a level in `access`; have an `admin` make access changes. | every host | | 409 | [`idempotency-conflict`](https://alvo.burgyn.online/reference/problem-types/#idempotency-conflict) | The `Idempotency-Key` was already used by this caller for a different request. | Use a fresh key for a new request. | every host | | 412 | [`precondition-failed`](https://alvo.burgyn.online/reference/problem-types/#precondition-failed) | `If-Match` names a revision that is no longer current. | Read the descriptor again, reapply your change to it, and send the new revision. | every host | | 422 | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | The descriptor is refused, for example an expression that does not compile; the violation's `pointer` names the place. | Fix what the violation's `fixSuggestion` says, and check the slot with `cel/check` first. | every host | | 428 | [`precondition-required`](https://alvo.burgyn.online/reference/problem-types/#precondition-required) | The write carried no `If-Match`. | Read the current revision and send it as `If-Match: ""`. | every host | ## Reference - [Management API](https://alvo.burgyn.online/reference/management-api/): every route and its body. - Descriptor keys: [`access`](https://alvo.burgyn.online/reference/descriptor/access/), and the [whole schema](https://alvo.burgyn.online/reference/descriptor/). - [CEL functions](https://alvo.burgyn.online/reference/cel-functions/) and [Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/), which an agent should read before proposing a key this build does not run. - Problem types: [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), [`idempotency-conflict`](https://alvo.burgyn.online/reference/problem-types/#idempotency-conflict), [`precondition-failed`](https://alvo.burgyn.online/reference/problem-types/#precondition-failed), [`precondition-required`](https://alvo.burgyn.online/reference/problem-types/#precondition-required), [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation). ## Next **[Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/)**: the full lifecycle of a change, from a dry run to a rollback. --- # Why Alvo Source: https://alvo.burgyn.online/start-here/why-alvo/ ## What it is *Describe your backend in one JSON file. Get a secure, production-shaped API — standalone in Docker or embedded in your ASP.NET Core app.* That file, the project descriptor, declares your entities, their fields, who may read and write which rows, what happens on every write, and who may change the project itself. Alvo turns it into database tables, a REST API with an OpenAPI document, rules enforced in the database, and an admin dashboard. It is written in .NET, and the same engine runs as a Docker container or as a library inside your own app. ## Why it is different - **One descriptor, the whole backend.** Entities, rules, hooks, computed fields, rollups, indexes, audit and webhooks live in one schema-validated JSON file, whether it sits in your repository or is edited in the dashboard. [The project descriptor](https://alvo.burgyn.online/concepts/descriptor/) - **Security in the data layer.** Rules are CEL expressions compiled to parameterized SQL predicates, everything is denied until a rule allows it, and hooks fail closed. [Security model](https://alvo.burgyn.online/concepts/security-model/) - **Agent-first.** A JSON Schema with a description on every key, structured errors with fix suggestions, idempotent operations, a Management API and `llms.txt`, so a coding agent can do the work. [For coding agents](https://alvo.burgyn.online/start-here/coding-agents/) - **.NET-native, two modes.** The Docker image and the embedded library are one codebase; embedded, you extend it in C# with your own functions and endpoints. [Standalone and embedded](https://alvo.burgyn.online/concepts/modes/) - **An admin dashboard** with a schema editor, rule and hook editors, a data browser, history with rollback, and an AI assistant that uses the same skills your agents can. [The admin dashboard](https://alvo.burgyn.online/guides/admin-dashboard/) - **Dynamic entities (planned).** Your end users define their own record types at runtime, in one shared store, in an embedded host. Not in this build. [Dynamic entities (planned)](https://alvo.burgyn.online/concepts/dynamic-entities/) ## When to use it - You want a real backend (data, access rules, validation, audit) without writing the CRUD, the migrations and the authorization checks yourself. - You work with a coding agent and want it to change the backend through a declarative file and an API that says what is wrong, rather than through generated controller code. - Your team is on .NET, and you want to grow from a container into your own ASP.NET Core host without rewriting the backend: the descriptor moves with you. - You need rules that hold for every request, enforced where the data is read, not in each endpoint. ## When not to use it - **You need it in production today.** Alvo is pre-v0.1: no NuGet package and no versioned image is released, and the format and APIs may still change. See [What works today](https://alvo.burgyn.online/start-here/what-works-today/). - **You need realtime subscriptions, file storage or automation rules (the `automation` block).** None of them runs in this build; after-hooks that send e-mail and webhooks on a write do. - **You need sign-in through Google, Microsoft or another identity provider.** Only local accounts and API keys exist in this build; embedded, your app can bring its own authentication. - **Your logic does not fit declarative rules and hooks.** If most of your backend is custom code, the escape hatch is the embedded mode with your own endpoints, but then weigh how much Alvo still does for you. ## Compared with How Alvo answers the same questions as the platforms closest to it: | | Runtime | Where rules run | Backend defined as | Extending in .NET | Embedding in your app | |---|---|---|---|---|---| | **Alvo** | .NET: a Docker image, or a library in your host | CEL compiled to SQL predicates, inside the database query | one JSON descriptor | C# functions, endpoints and providers in the embedded mode | yes, in an ASP.NET Core app | | **Supabase** | about seven services around PostgreSQL (gateway, auth, PostgREST, realtime, storage, functions, studio) | PostgreSQL row-level security policies, in the database | SQL: schema, policies and migrations | a community C# client; server-side logic in Deno edge functions | no | | **PocketBase** | a single Go binary over SQLite | API rules per collection and operation, in PocketBase's filter syntax, applied as a filter on the record query (superusers bypass them) | collections with their API rules | no; extended in Go or with JavaScript hooks | yes, in a Go app | | **Hand-written ASP.NET Core** | your own .NET app | a check in each endpoint, written by you | C# code | everything is .NET | it is your app | ## Status Alvo is being built in the open, phase by phase. [Roadmap and status](https://alvo.burgyn.online/project/roadmap/) shows where it is, what v0.1 brings, and what may change before then. --- # Quick start Source: https://alvo.burgyn.online/start-here/quick-start/ :::tip[What you will achieve] A running Alvo backend over PostgreSQL, and a record you created through its API. About five minutes, starting from nothing but Docker and one downloaded file: no clone, no build. ::: The stack runs the published image, `ghcr.io/burgyn/alvo` (for `linux/amd64` and `linux/arm64`). Its `edge` tag follows the repository's `main` branch; Alvo is pre-v0.1, so there is no release tag yet. ## Before you start - Docker with Compose v2, `curl` and `openssl`. - Port 8080 free on this machine. The stack listens on `127.0.0.1:8080` only. ## Run it ```sh curl -fsSLO https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/docker-compose.quickstart.yml export ALVO_DEMO_KEY_SECRET="$(openssl rand -hex 16)" export ALVO_ADMIN_PASSWORD="$(openssl rand -hex 12)" docker compose -f docker-compose.quickstart.yml up --wait --wait-timeout 90 curl -sS localhost:8080/api/owners -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` Line by line: download the compose file; generate the secret of the stack's one API key and the dashboard administrator's password (compose refuses to start without either, because the image ships no credential); pull the images and start Alvo next to PostgreSQL, waiting up to 90 seconds until the descriptor is applied; list the owners, which is an empty page for now. **How long the first run takes.** The first run pulls two images, Alvo's and PostgreSQL's, about 700 MB on disk together (measured with a local `linux/arm64` build of the image), so it takes as long as your connection needs for that. With both images already pulled, the stack was ready about 5 seconds after `up` on an Apple M4 laptop with Docker Desktop. Later runs reuse the images. ## Create, then list The stack's key is `demo`, with the built-in roles `admin` and `authenticated`; its secret is the `ALVO_DEMO_KEY_SECRET` you exported. Create an owner, then list the owners again: ```sh curl -sS -X POST http://localhost:8080/api/owners \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Fleet Desk Ltd","email":"office@fleetdesk.example"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155268054220" Location: /api/owners/6e9f8524-2c97-486a-bbb3-04be686418e5 { "id": "6e9f8524-2c97-486a-bbb3-04be686418e5", "created_at": "2026-10-11T11:38:46.805422+00:00", "created_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "email": "office@fleetdesk.example", "name": "Fleet Desk Ltd", "phone": null, "updated_at": "2026-10-11T11:38:46.805422+00:00", "updated_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a" } ``` ```sh curl -sS -X GET http://localhost:8080/api/owners \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "id": "6e9f8524-2c97-486a-bbb3-04be686418e5", "created_at": "2026-10-11T11:38:46.805422+00:00", "created_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "email": "office@fleetdesk.example", "name": "Fleet Desk Ltd", "phone": null, "updated_at": "2026-10-11T11:38:46.805422+00:00", "updated_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a" } ], "next": null, "count": null } ``` The `Location` header points at the new record, and the list now holds it. These responses were captured from a real run when the site was built, so your ids, timestamps and ETag will differ. Every write needs the `Content-Type: application/json` header as well as the key. ## What just happened The stack serves [`examples/vehicle-registry`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/vehicle-registry/vehicles.alvo.json), which the image carries at `/alvo/examples/vehicle-registry/vehicles.alvo.json`. The descriptor has three entities: `owners`, their `vehicles`, and the vehicles' `inspections`. From that one file Alvo created the tables in PostgreSQL and serves a REST API for each entity, with the access rules the descriptor declares: any authenticated caller may read owners, and only an `admin` may create one. Nobody wrote a controller, a migration or an authorization check. ## Explore the API Each descriptor generates its own OpenAPI document. While the stack runs: - `http://localhost:8080/scalar` is an API browser over every route this descriptor produced. - `http://localhost:8080/openapi/v1.json` is the document itself, for a client generator or a coding agent. Both are readable without a key; every data route needs one. ## Open the dashboard The same stack serves the admin dashboard at `http://localhost:8080/admin`. Sign in as `admin@alvo.local` with the password you exported in `ALVO_ADMIN_PASSWORD`; compose hands it to the host as a mounted file, and the account is created on the first start against an empty database. [The admin dashboard](https://alvo.burgyn.online/guides/admin-dashboard/) walks through its screens. ## Serve another example The image carries every example that applies, under `/alvo/examples//.alvo.json`. `ALVO_DESCRIPTOR` picks one, for example the smallest real backend, projects and their tasks: ```sh docker compose -f docker-compose.quickstart.yml down --volumes ALVO_DESCRIPTOR=/alvo/examples/simple-tasks/tasks.alvo.json docker compose -f docker-compose.quickstart.yml up --wait ``` The `down --volumes` deletes the database first, because a database holds the schema of the descriptor it served and Alvo refuses to drop the old tables on its own. The demo key's roles are built in, so it authenticates against every example; what it may do there is that descriptor's rules. [Examples](https://alvo.burgyn.online/examples/) lists them all, and the header of `docker-compose.quickstart.yml` says what to add for the multi-tenant `field-service`. ## Tear down Stop the stack and delete its database volume. Run it in the same shell, because compose reads `ALVO_DEMO_KEY_SECRET` for every command, `down` included: ```sh docker compose -f docker-compose.quickstart.yml down --volumes ``` ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 401 | [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated) | The `X-Alvo-Api-Key` header was sent but cannot be used: a typo, or a secret from another shell. | Send `demo.` followed by the secret the stack was started with. | every host | A request with no key at all is anonymous, not unauthenticated: it reads an empty page or is refused with 403 [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), because no rule admits an anonymous caller. If `docker compose up` fails with `required variable ALVO_DEMO_KEY_SECRET is missing a value`, export the secret in this shell first, and `ALVO_ADMIN_PASSWORD` with it. If the stack serves another descriptor than the vehicle registry, `ALVO_DESCRIPTOR` is still exported in this shell: `unset ALVO_DESCRIPTOR`. ## Reference - The descriptor: [`examples/vehicle-registry`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/vehicle-registry/vehicles.alvo.json), and the [API it generates](https://alvo.burgyn.online/data-api/conventions/). - Problem types: [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated), [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden). - The compose file: [`docker-compose.quickstart.yml`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docker-compose.quickstart.yml), whose header lists every variable it reads; the host in depth: [`docs/architecture/host.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/host.md). ## Next **[Tutorial: your first backend](https://alvo.burgyn.online/start-here/tutorial/)**: write a descriptor of your own, one step at a time. To serve a descriptor you already have, see [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). :::tip[With a coding agent] Give your agent the descriptor skills first, so it writes descriptors Alvo accepts. In Claude Code: ```txt /plugin marketplace add Burgyn/MMLib.Alvo /plugin install alvo@mmlib-alvo ``` [For coding agents](https://alvo.burgyn.online/start-here/coding-agents/#3-load-the-shared-skills) has the same skills for other agents, and the rest: the schema, dry runs and reading refusals. ::: --- # Tutorial: your first backend Source: https://alvo.burgyn.online/start-here/tutorial/ :::tip[What you will achieve] A working help-desk API: tickets that support agents can file and edit, that only an administrator can delete, whose title is trimmed on the way in, and whose cost is computed from the estimate. About ten minutes, starting from Docker and an empty directory: no clone, no build. ::: ## Before you start - Docker with Compose v2, `curl` and `openssl`, and port 8080 free (stop the [Quick start](https://alvo.burgyn.online/start-here/quick-start/)'s stack if it still runs). You run the published image the way [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/) explains; this page gives you every command. - Two API keys, declared by the override file the first step downloads: `agent` (roles `agent`, `authenticated`) and `admin` (roles `admin`, `authenticated`). Their secrets are the variables `ALVO_AGENT_KEY_SECRET` and `ALVO_ADMIN_KEY_SECRET`. Run every command on this page in the same shell and directory, so they stay exported. Each step changes one file, `help-desk.alvo.json` in the `alvo-help-desk` directory the first step creates; the stack mounts it read-only. Edit it by hand to match the descriptor shown, or download the finished step with the command beside it. The responses on this page were captured from a real run when the site was built, so the ids, timestamps and ETags you get will differ. ## 1. Describe an entity A descriptor names the project, the roles its callers can hold, and its entities. This one has a single entity, `tickets`, with five fields and `audit: true`, which records who created and changed each row, and when. 1. Start the stack over the first version of the descriptor. The commands create the directory, download the quick start's compose file, the key override and the descriptor, generate the secrets, and wait until the API answers: ```sh mkdir -p alvo-help-desk && cd alvo-help-desk curl -fsSLO https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/docker-compose.quickstart.yml curl -fsSL -o docker-compose.override.yml https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/shell/help-desk.compose.override.yml curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/tutorial/01-entity.alvo.json export COMPOSE_FILE=docker-compose.quickstart.yml:docker-compose.override.yml export ALVO_DEMO_KEY_SECRET="$(openssl rand -hex 16)" ALVO_ADMIN_PASSWORD="$(openssl rand -hex 12)" export ALVO_AGENT_KEY_SECRET="$(openssl rand -hex 16)" ALVO_ADMIN_KEY_SECRET="$(openssl rand -hex 16)" docker compose up --wait --wait-timeout 90 ``` 2. This is the descriptor the stack now serves: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "providers": ["local"], "roles": ["admin", "agent"] }, "entities": { "tickets": { "audit": true, "fields": { "title": { "type": "string", "required": true, "maxLength": 120 }, "body": { "type": "text" }, "priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" }, "status": { "type": "enum", "values": ["open", "closed"], "default": "open" }, "estimate_hours": { "type": "decimal", "precision": 5, "scale": 1 } }, "rules": { "list": "'authenticated' in @user.roles", "get": "'authenticated' in @user.roles", "create": "'authenticated' in @user.roles" } } } } ``` Each entity has `rules`, one CEL condition per operation. Alvo is **default-deny**: an operation with no rule is refused for everyone, so an entity without `rules` answers nothing at all. Here any authenticated caller may list, read and create tickets, and nobody may update or delete them yet. 3. File a ticket as the `agent` key, then list the tickets: ```sh 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":1.5}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155287100400" Location: /api/tickets/b467d3c2-f39f-4f5f-a762-81fbddb85f12 { "id": "b467d3c2-f39f-4f5f-a762-81fbddb85f12", "body": null, "created_at": "2026-10-11T11:38:48.71004+00:00", "created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01", "estimate_hours": 1.5, "priority": "normal", "status": "open", "title": "Printer on fire", "updated_at": "2026-10-11T11:38:48.71004+00:00", "updated_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01" } ``` ```sh curl -sS -X GET http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "id": "b467d3c2-f39f-4f5f-a762-81fbddb85f12", "body": null, "created_at": "2026-10-11T11:38:48.71004+00:00", "created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01", "estimate_hours": 1.5, "priority": "normal", "status": "open", "title": "Printer on fire", "updated_at": "2026-10-11T11:38:48.71004+00:00", "updated_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01" } ], "next": null, "count": null } ``` The response carries the defaults (`priority`, `status`) and the audit columns Alvo maintains. 4. Leave out the required `title`, and the write is refused before anything is stored: ```sh 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 '{"priority":"high"}' ``` ```http HTTP/1.1 422 Unprocessable Entity Content-Type: application/problem+json { "type": "https://alvo.dev/errors/validation", "title": "Unprocessable Entity", "status": 422, "detail": "A field the entity declares required is missing or null.", "violations": [ { "pointer": "/title", "code": "required", "message": "A field the entity declares required is missing or null.", "fixSuggestion": "Supply a value for it. A create must carry every required field; a partial update may omit any field it is not changing, but may not null a required one." } ] } ``` Every refusal is a problem document like this one: `type` says what kind of refusal it is, and each violation names the field, a stable `code` and a fix. ## 2. Decide who may do what Rules are CEL expressions over the caller (`@user`) and the row. Alvo compiles them to SQL predicates, so they hold for every request, including a list that matches thousands of rows. 1. Let agents and administrators create and update tickets, and let only an administrator delete one: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "providers": ["local"], "roles": ["admin", "agent"] }, "entities": { "tickets": { "audit": true, "fields": { "title": { "type": "string", "required": true, "maxLength": 120 }, "body": { "type": "text" }, "priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" }, "status": { "type": "enum", "values": ["open", "closed"], "default": "open" }, "estimate_hours": { "type": "decimal", "precision": 5, "scale": 1 } }, "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", "delete": "'admin' in @user.roles" } } } } ``` 2. Apply it by recreating the `alvo` container. On start, Alvo applies a descriptor change that discards nothing, and refuses one that would. ([Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/) covers the other ways to apply a change, including the Management API without a restart.) ```sh curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/tutorial/02-rules.alvo.json docker compose up -d --wait --force-recreate alvo ``` 3. An agent files a ticket, then tries to delete it: ```sh 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":"Reset my password"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155292266340" Location: /api/tickets/cd419cac-760c-435b-bf7c-3384fd059e5f { "id": "cd419cac-760c-435b-bf7c-3384fd059e5f", "title": "Reset my password", "status": "open" } ``` ```sh curl -sS -X DELETE http://localhost:8080/api/tickets/cd419cac-760c-435b-bf7c-3384fd059e5f \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" ``` ```http HTTP/1.1 404 Not Found Content-Type: application/problem+json { "type": "https://alvo.dev/errors/not-found", "title": "Not Found", "status": 404, "detail": "The requested record was not found." } ``` A **404, not a 403**. A `delete` rule works like a row filter: a caller it excludes cannot see the row, and Alvo answers as if the row were not there, so nobody learns which rows exist that they may not touch. [Access rules](https://alvo.burgyn.online/guides/access-rules/) lists exactly when Alvo answers 403 instead. 4. The `admin` key deletes the same ticket: ```sh curl -sS -X DELETE http://localhost:8080/api/tickets/cd419cac-760c-435b-bf7c-3384fd059e5f \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" ``` ```http HTTP/1.1 204 No Content ``` ## 3. Clean up and check every write Before-hooks run inside the write's transaction, before the row is stored. A `mutate` action rewrites a field from a CEL expression over `new`, the row being written. A `reject` action refuses the write when its `condition` is true. 1. Trim every new ticket's title with the built-in `trim` function, and refuse a high-priority ticket that has no body: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "providers": ["local"], "roles": ["admin", "agent"] }, "entities": { "tickets": { "audit": true, "fields": { "title": { "type": "string", "required": true, "maxLength": 120 }, "body": { "type": "text" }, "priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" }, "status": { "type": "enum", "values": ["open", "closed"], "default": "open" }, "estimate_hours": { "type": "decimal", "precision": 5, "scale": 1 } }, "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", "delete": "'admin' in @user.roles" }, "hooks": { "beforeCreate": [ { "action": { "mutate": { "title": { "$cel": "trim(new.title)" } } } }, { "condition": "new.priority == 'high' && !has(new.body)", "action": { "reject": "A high-priority ticket needs a body." } } ] } } } } ``` 2. Apply it: ```sh curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/tutorial/03-hook.alvo.json docker compose up -d --wait --force-recreate alvo ``` 3. A padded title comes back trimmed: ```sh 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":" VPN drops every hour "}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155297543840" Location: /api/tickets/d091b978-63ee-4d53-860e-e1fbace77ac8 { "id": "d091b978-63ee-4d53-860e-e1fbace77ac8", "title": "VPN drops every hour", "priority": "normal" } ``` 4. A high-priority ticket without a body is refused, with the hook's own text in `detail`: ```sh 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":"Server room is flooding","priority":"high"}' ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json { "type": "https://alvo.dev/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "A high-priority ticket needs a body. (refused by the before-hook at '/entities/tickets/hooks/beforeCreate/1')" } ``` A `reject` is a policy refusal, so its status is 403 `forbidden`, the same as a rule's. The detail also names the hook that refused, so you can find it in the descriptor. ## 4. Compute a value A `computed` field is derived from the row's own fields, stored as a generated column, and never written by a caller. 1. Add an hourly rate and a computed cost. This is the finished descriptor, the same file as [`examples/help-desk`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/help-desk/help-desk.alvo.json): ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "providers": ["local"], "roles": ["admin", "agent"] }, "entities": { "tickets": { "audit": true, "fields": { "title": { "type": "string", "required": true, "maxLength": 120 }, "body": { "type": "text" }, "priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" }, "status": { "type": "enum", "values": ["open", "closed"], "default": "open" }, "estimate_hours": { "type": "decimal", "precision": 5, "scale": 1 }, "hourly_rate": { "type": "decimal", "precision": 6, "scale": 2 }, "estimate_cost": { "type": "decimal", "precision": 12, "scale": 2, "computed": "estimate_hours * hourly_rate" } }, "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", "delete": "'admin' in @user.roles" }, "hooks": { "beforeCreate": [ { "action": { "mutate": { "title": { "$cel": "trim(new.title)" } } } }, { "condition": "new.priority == 'high' && !has(new.body)", "action": { "reject": "A high-priority ticket needs a body." } } ] } } } } ``` A computed expression combines the row's own fields. A number or other non-text constant, as in `estimate_hours * 60`, is refused when the descriptor is applied, because a generated column cannot take a parameter (a text constant joined to a field is allowed). Keep such a value in a field of its own, as `hourly_rate` does here. 2. Apply it: ```sh curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/examples/help-desk/help-desk.alvo.json docker compose up -d --wait --force-recreate alvo ``` 3. File a ticket with an estimate and a rate. The response carries `estimate_cost`: ```sh 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":"Replace the office router","estimate_hours":2.5,"hourly_rate":40}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155302695050" Location: /api/tickets/22a7f8c9-b6e5-4a54-8d5d-3c1efe2e9564 { "id": "22a7f8c9-b6e5-4a54-8d5d-3c1efe2e9564", "title": "Replace the office router", "estimate_hours": 2.5, "hourly_rate": 40.0, "estimate_cost": 100.0 } ``` ## 5. See it in the dashboard The stack also serves the admin dashboard at `http://localhost:8080/admin`. The compose file created a bootstrap administrator, `admin@alvo.local`, whose password is the `ALVO_ADMIN_PASSWORD` the first step generated: ```sh echo "$ALVO_ADMIN_PASSWORD" ``` Sign in and open **Schema**, then **tickets**. The *Fields* tab lists the seven declared fields with their facets and marks `estimate_cost` as computed; *Rules* holds the five rules and *On write* the two before-hooks. **Data** browses the tickets you created through the API. When you are done, stop the stack and delete its database. The files stay in `alvo-help-desk` for next time; the `unset` makes a plain `docker compose` in this shell stop reaching for the override: ```sh docker compose down --volumes unset COMPOSE_FILE ``` ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 401 | [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated) | A key was sent and cannot be used: a wrong secret, or a role the descriptor does not declare. | Check that the secret variables are still exported in this shell, and that every role on the key is in `auth.roles` or built in. | every host | | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | The operation has no rule, a `create` rule refused the row, or a before-hook's `reject` fired. | Add or widen the rule, or send a row the hook's condition does not match. | every host | | 404 | [`not-found`](https://alvo.burgyn.online/reference/problem-types/#not-found) | The row does not exist, or the `get`, `update` or `delete` rule excludes it for this caller. | Check the rule for that operation. | every host | | 422 | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | The body breaks the entity's declared shape, such as a missing required `title`. | Follow the violation's `pointer` and `fixSuggestion`. | every host | If `docker compose up` fails after you change the descriptor, the new version did not apply. The reason is at the end of `docker compose logs alvo`; see [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/#what-can-go-wrong). ## What you built - An entity, `tickets`, with a required, length-limited title, two enums with defaults, two decimals and audit columns. - Access rules per operation: reads for any authenticated caller, writes for agents and administrators, deletes for administrators only. - Two before-hooks: one normalises the title with a built-in function, one refuses an incomplete urgent ticket. - A computed field derived from two others. ## Reference - Descriptor keys: [`entities`](https://alvo.burgyn.online/reference/descriptor/entities/), [fields](https://alvo.burgyn.online/reference/descriptor/entities-fields/), [rules](https://alvo.burgyn.online/reference/descriptor/entities-rules/), [hooks](https://alvo.burgyn.online/reference/descriptor/entities-hooks/), [computed fields](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/), [`auth`](https://alvo.burgyn.online/reference/descriptor/auth/). - CEL functions: `trim`, in the [CEL function catalog](https://alvo.burgyn.online/reference/cel-functions/). - Problem types: [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated), [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), [`not-found`](https://alvo.burgyn.online/reference/problem-types/#not-found), [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation). ## Next **[Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/)**: point the same stack at a file you wrote, with keys for the roles it declares. :::tip[With a coding agent] Give your agent the descriptor skills first, so it writes descriptors Alvo accepts. In Claude Code: ```txt /plugin marketplace add Burgyn/MMLib.Alvo /plugin install alvo@mmlib-alvo ``` [For coding agents](https://alvo.burgyn.online/start-here/coding-agents/#3-load-the-shared-skills) has the same skills for other agents, and the rest: the schema, dry runs and reading refusals. ::: --- # Run your own descriptor Source: https://alvo.burgyn.online/start-here/run-your-own/ :::tip[What you will achieve] Your own descriptor behind the standalone stack (Alvo over PostgreSQL), with API keys that work for the roles it declares. A few minutes with Docker and three downloaded files: no clone, no build and no .NET SDK. ::: ## Before you start - Docker with Compose v2, `curl` and `openssl`, and port 8080 free. If the [Quick start](https://alvo.burgyn.online/start-here/quick-start/)'s stack is still running, stop it first: both listen on `127.0.0.1:8080`. - A descriptor file. If you do not have one yet, the commands below use [`examples/help-desk`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/help-desk/help-desk.alvo.json), and [Start from an example](#start-from-an-example) takes `examples/simple-tasks` as a starting point. ## 1. Declare keys your descriptor knows The quick start's `docker-compose.quickstart.yml` defines one dev key, `demo`. Its roles are the built-in `admin` and `authenticated`, so it authenticates against any descriptor, but **a key with one role the descriptor does not declare authenticates nothing**: every request with it is a 401 `unauthenticated`. A key's roles must be listed in the descriptor's `auth.roles` or be built in (`authenticated` and `admin` are). So declare keys for your own roles in a compose override, a second file that compose merges over the first: ```yaml # Saved as docker-compose.override.yml next to docker-compose.quickstart.yml. With # COMPOSE_FILE=docker-compose.quickstart.yml:docker-compose.override.yml exported, every `docker compose` # command merges it over the quick start's stack. It serves ./help-desk.alvo.json instead of an example # inside the image, runs as its own project (its own database), and adds two dev keys for examples/help-desk # beside the quick start's demo key (index 0). Every role a key carries must be declared in the descriptor's # auth.roles or be built in (authenticated, admin): a key with one undeclared role authenticates nothing. name: alvo-help-desk services: alvo: environment: Alvo__DescriptorPath: /alvo/descriptor.json Alvo__Auth__DevKeys__1__KeyId: agent Alvo__Auth__DevKeys__1__Secret: ${ALVO_AGENT_KEY_SECRET:?set ALVO_AGENT_KEY_SECRET before starting the stack} Alvo__Auth__DevKeys__1__User: 3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01 Alvo__Auth__DevKeys__1__Roles__0: agent Alvo__Auth__DevKeys__1__Roles__1: authenticated Alvo__Auth__DevKeys__1__Scopes__0: "*:read" Alvo__Auth__DevKeys__1__Scopes__1: "*:write" Alvo__Auth__DevKeys__2__KeyId: admin Alvo__Auth__DevKeys__2__Secret: ${ALVO_ADMIN_KEY_SECRET:?set ALVO_ADMIN_KEY_SECRET before starting the stack} Alvo__Auth__DevKeys__2__User: 8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02 Alvo__Auth__DevKeys__2__Roles__0: admin Alvo__Auth__DevKeys__2__Roles__1: authenticated Alvo__Auth__DevKeys__2__Scopes__0: "*:read" Alvo__Auth__DevKeys__2__Scopes__1: "*:write" volumes: - ./help-desk.alvo.json:/alvo/descriptor.json:ro ``` It adds two keys for `help-desk`, whose `auth.roles` are `admin` and `agent`: `agent` (roles `agent`, `authenticated`) and `admin` (roles `admin`, `authenticated`). Change the `KeyId`s and roles to your descriptor's. Each secret is read from a variable, and the `:?` form makes compose refuse to start rather than invent one; a secret must be at least 32 characters. The override also mounts `./help-desk.alvo.json` read-only at `/alvo/descriptor.json` and points the host at it, so the stack serves your file instead of an example inside the image. To serve a file with another name, change the left side of that `volumes` line. `name: alvo-help-desk` gives this stack its own database, apart from the quick start's. ## 2. Start the stack over your descriptor 1. Make a directory for the stack, download the compose file, the override and the descriptor, generate the secrets, and start it: ```sh mkdir -p alvo-help-desk && cd alvo-help-desk curl -fsSLO https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/docker-compose.quickstart.yml curl -fsSL -o docker-compose.override.yml https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/shell/help-desk.compose.override.yml curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/examples/help-desk/help-desk.alvo.json export COMPOSE_FILE=docker-compose.quickstart.yml:docker-compose.override.yml export ALVO_DEMO_KEY_SECRET="$(openssl rand -hex 16)" ALVO_ADMIN_PASSWORD="$(openssl rand -hex 12)" export ALVO_AGENT_KEY_SECRET="$(openssl rand -hex 16)" ALVO_ADMIN_KEY_SECRET="$(openssl rand -hex 16)" docker compose up --wait --wait-timeout 90 curl -sS localhost:8080/api/tickets -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" ``` `COMPOSE_FILE` names both files, so every plain `docker compose` command in this shell uses them together. `--wait` returns once the API is ready, which means the descriptor is applied. The last command lists the tickets with the `agent` key: an empty page, because nothing has been written yet. 2. Every compose command reads both files again, `down` included, so keep these variables exported in the shell you use for the stack, or put them in a `.env` file next to the compose files, which compose reads by itself. The API is at `http://localhost:8080/api/`, its OpenAPI document at `/openapi/v1.json`, an API browser at `/scalar`, and the dashboard at `/admin`, where you sign in as `admin@alvo.local` with `ALVO_ADMIN_PASSWORD`. The [Tutorial](https://alvo.burgyn.online/start-here/tutorial/) walks through this exact stack one change at a time. ## 3. Change it Edit `help-desk.alvo.json`, then recreate the `alvo` container so it reads the file again: ```sh docker compose up -d --wait --force-recreate alvo ``` On start, Alvo compares the descriptor with the schema it applied before. A change that discards nothing (a new entity, a new field, a changed rule) is applied; one that would drop or narrow data is refused, and the container stops with the plan in its log. To apply without a restart, send the descriptor to the Management API instead; both paths, with dry runs, revisions and rollback, are in [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/). In this build everything but a new entity takes effect that way at once; a new entity gets its Data API route after a restart ([#103](https://github.com/Burgyn/MMLib.Alvo/issues/103)). ## Start from an example `examples/simple-tasks` is the smallest real backend: projects and the tasks inside them, owned by the user who created them. Download it over the mounted file, and from then on it is your descriptor: ```sh docker compose down --volumes curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/examples/simple-tasks/tasks.alvo.json docker compose up --wait --wait-timeout 90 curl -sS localhost:8080/api/projects -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" ``` The first line deletes the stack's database. A database holds the schema of the descriptor it served; starting a different descriptor over it would drop the old tables, and Alvo refuses that (see [What can go wrong](#what-can-go-wrong)). `simple-tasks` declares no `auth.roles`, so of the override's keys only `admin` works there (its roles are both built in, like the `demo` key's); the `agent` key answers 401 until you add `"roles": ["agent"]` to the descriptor's `auth` block, or remove `agent` from the key. ## Tear it down Stop the stack and delete its database, in the same shell. The `unset` makes a plain `docker compose` in this shell stop reaching for the override; the quick start's commands name their file with `-f` anyway: ```sh docker compose down --volumes unset COMPOSE_FILE ``` ## Without Docker
Run the host with the .NET SDK and SQLite instead The same descriptor and the same two keys, on the host process directly, from a clone of the repository (`git clone https://github.com/Burgyn/MMLib.Alvo`; run the commands in its root). You need the .NET SDK the repository's `global.json` pins. The database is a SQLite file in your temporary directory, and the host listens on the address the stack uses, so stop the stack first. ```sh export ALVO_DESCRIPTOR="$PWD/examples/help-desk/help-desk.alvo.json" export ALVO_AGENT_KEY_SECRET="$(openssl rand -hex 16)" export ALVO_ADMIN_KEY_SECRET="$(openssl rand -hex 16)" openssl rand -base64 24 > .alvo-admin-password dotnet run --project src/MMLib.Alvo.Host -- \ --environment Development --urls http://127.0.0.1:8080 \ --Alvo:DescriptorPath="$ALVO_DESCRIPTOR" \ --Alvo:Database:Provider=sqlite \ "--Alvo:Database:SqliteConnectionString=Data Source=${TMPDIR:-/tmp}/alvo-help-desk.db" \ --Alvo:Admin:BootstrapEmail=admin@example.com \ --Alvo:Admin:BootstrapPasswordFile="$PWD/.alvo-admin-password" \ --Alvo:Auth:DevKeys:0:KeyId=agent --Alvo:Auth:DevKeys:0:Secret="$ALVO_AGENT_KEY_SECRET" \ --Alvo:Auth:DevKeys:0:User=3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01 \ --Alvo:Auth:DevKeys:0:Roles:0=agent --Alvo:Auth:DevKeys:0:Roles:1=authenticated \ "--Alvo:Auth:DevKeys:0:Scopes:0=*:read" "--Alvo:Auth:DevKeys:0:Scopes:1=*:write" \ --Alvo:Auth:DevKeys:1:KeyId=admin --Alvo:Auth:DevKeys:1:Secret="$ALVO_ADMIN_KEY_SECRET" \ --Alvo:Auth:DevKeys:1:User=8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02 \ --Alvo:Auth:DevKeys:1:Roles:0=admin --Alvo:Auth:DevKeys:1:Roles:1=authenticated \ "--Alvo:Auth:DevKeys:1:Scopes:0=*:read" "--Alvo:Auth:DevKeys:1:Scopes:1=*:write" ``` `Ctrl+C` stops it. A change to the descriptor applies on the next start, as with the stack.
## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 401 | [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated) | The key carries a role the descriptor does not declare (the `agent` key over a descriptor without `agent`), or the secret is wrong. | Give the key only declared or built-in roles; export the same secret the stack was started with. | every host | **The stack does not start.** `docker compose up --wait` fails and the `alvo` container has exited. Read why: ```sh docker compose logs alvo ``` The end of the log says what to change. The common reasons: - **The descriptor is invalid.** The log shows `Descriptor validation failed:` and one line per problem: its JSON pointer, for example `/entities/tickets/fields/estimate_cost/computed`, what is wrong and how to fix it. Correct the file and start again. :::caution[Known issue in this build] This refusal arrives as an unhandled `DescriptorValidationException`, and the container exits with code 139 instead of the clean exit 78 below ([#340](https://github.com/Burgyn/MMLib.Alvo/issues/340)). The messages are the same. ::: - **The change would discard data.** The log says `Alvo cannot start:`, lists the plan with the destructive steps marked (such as `DropEntity tickets`), and the container exits with code 78. Keep what the descriptor dropped, or, if the data is disposable, start over with an empty database: ```sh docker compose down --volumes ``` - **A variable is missing.** Compose itself refuses, naming the variable (`set ALVO_AGENT_KEY_SECRET before starting the stack`), before any container starts. Export it again. ## Reference - Configuration: [`Alvo:Auth` dev keys and every other key](https://alvo.burgyn.online/reference/configuration/), [the dev-key secret minimum](https://alvo.burgyn.online/reference/limits/). - Descriptor keys: [`auth.roles`](https://alvo.burgyn.online/reference/descriptor/auth/). - Problem types: [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated). - The compose file: [`docker-compose.quickstart.yml`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docker-compose.quickstart.yml); the host and the startup mode, in depth: [`docs/architecture/host.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/host.md). ## Next **[Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/)**: change a running backend safely, with dry runs, revisions and rollback. --- # Embed Alvo in ASP.NET Core Source: https://alvo.burgyn.online/start-here/embed/ :::tip[What you will achieve] Alvo inside your own ASP.NET Core app: the generated API under a prefix of your choice, beside your own routes, driven by the same descriptor the Docker image serves. About fifteen minutes, starting from an existing web project on .NET 10. ::: ## Before you start - The .NET 10 SDK and an ASP.NET Core project (`dotnet new web` is enough), plus a clone of the repository: Alvo is pre-v0.1 and **no package is on NuGet yet**. - A descriptor. The code below uses `examples/vehicle-registry`; [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/) is the Docker-only way to try one first. ## 1. Reference the packages Two packages are the minimum: `MMLib.Alvo`, the core (schema registry, Data API, rule engine, events, management), and one database driver, `MMLib.Alvo.Data.Sqlite` or `MMLib.Alvo.Data.PostgreSql`. The core never references a driver; the driver you add is the one you select in code. Every package is listed in the [C# API reference](https://alvo.burgyn.online/reference/csharp/). Today you either reference the projects in a clone or install from a local package feed you pack yourself; `dotnet add package` from NuGet works from v0.1. Reference the projects in your clone directly. `ALVO_CLONE` is where you cloned the repository: ```sh CLONE="${ALVO_CLONE:-$HOME/MMLib.Alvo}" dotnet add reference "$CLONE/src/MMLib.Alvo/MMLib.Alvo.csproj" dotnet add reference "$CLONE/src/MMLib.Alvo.Data.Sqlite/MMLib.Alvo.Data.Sqlite.csproj" ``` Pack the clone into a folder, register that folder as a package source in your project's `nuget.config` (so every later restore finds it), and install from it. The packages carry a pre-release version, hence `--prerelease`: ```sh FEED="${ALVO_FEED:-$HOME/alvo-feed}" CLONE="${ALVO_CLONE:-$HOME/MMLib.Alvo}" (cd "$CLONE" && dotnet pack -c Release -o "$FEED") dotnet new nugetconfig dotnet nuget add source "$FEED" -n alvo-local --configfile nuget.config dotnet add package MMLib.Alvo --prerelease dotnet add package MMLib.Alvo.Data.Sqlite --prerelease ``` :::caution[Not published yet] These commands fail today: `MMLib.Alvo` is not on NuGet before v0.1. Use one of the other two tabs. ::: ```sh dotnet add package MMLib.Alvo dotnet add package MMLib.Alvo.Data.Sqlite ``` ## 2. Register Alvo The whole app fits in one `Program.cs`. `AddAlvo` is the one entry point: `UseSqlite` selects the database, `FromDescriptor` supplies the descriptor (here `vehicles.alvo.json`, copied beside the project), and `AddDataApi` mounts the generated API under `/api/alvo` so it never collides with your own routes: ```csharp using MMLib.Alvo.Auth; var builder = WebApplication.CreateBuilder(args); // Alvo's own API keys, from configuration (Alvo:Auth). builder.Services.Configure(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")); var app = builder.Build(); app.MapAlvoHealth(); app.MapAlvoDataApi(); app.Run(); ``` There is no call to apply the descriptor: `AddAlvo` registers a hosted service that brings the schema up before the app starts serving, and `/health/ready` answers 200 once it has. The API keys come from configuration, which is what the `Configure` line binds. Declare a dev key in `appsettings.json`, deliberately without a secret, as the repository's embedded sample does: ```json "Auth": { "DevKeys": [ { "KeyId": "agent", "User": "9f1d3c7e-5b2a-4f18-8c6d-2e7a9b4c1d05", "Roles": [ "admin", "authenticated" ], "Scopes": [ "*:read", "*:write" ] } ] } ``` Alvo refuses to start without a secret of at least 32 characters, so the app cannot ship a working default credential. Supply it outside source control, with user secrets or an environment variable. ## 3. Map the endpoints Mapping is a separate step, so the routes land exactly where your pipeline wants them. `MapAlvoHealth` maps `/health/live` and `/health/ready` on their own. `MapAlvoDataApi` returns a convention builder over Alvo's generated data routes and nothing else, so you can attach your own rate limiting, telemetry, tags or an authorization policy to them, and none of it ever reaches the probes, which a container calls without a credential. ## 4. Run it To try the pattern without writing a project, run the repository's embedded sample: it serves the same descriptor with the same registration, and adds a few endpoints of its own. Give its dev key a secret, then start it from the clone: ```sh dotnet user-secrets --project samples/MMLib.Alvo.Samples.EmbeddedHost \ set "Alvo:Auth:DevKeys:0:Secret" "$(openssl rand -hex 16)" dotnet run --project samples/MMLib.Alvo.Samples.EmbeddedHost ``` It listens on `http://localhost:5199` and keeps a SQLite file beside itself. The sample's README shows a request to each of its two surfaces under [*Try both*](https://github.com/Burgyn/MMLib.Alvo/tree/main/samples/MMLib.Alvo.Samples.EmbeddedHost#try-both). ## Add the dashboard (optional) The admin dashboard and the schema assistant are three more packages: `MMLib.Alvo.Admin` (the dashboard, a server-interactive Blazor app), `MMLib.Alvo.Identity` (local accounts and the cookie sign-in) and `MMLib.Alvo.Ai` (the assistant). The same `Program.cs` with the dashboard added: ```csharp using Microsoft.EntityFrameworkCore; using MMLib.Alvo.Admin; using MMLib.Alvo.Auth; using MMLib.Alvo.Identity; using System.Security.Claims; var builder = WebApplication.CreateBuilder(args); builder.Services.Configure(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")); // The dashboard: local accounts and their cookie sign-in, the dashboard itself, and the schema assistant. builder.Services.AddAlvoIdentity( store => store.UseSqlite("Data Source=alvo-identity.db"), identity => builder.Configuration.GetSection(AlvoIdentity.ConfigurationSection).Bind(identity)); builder.Services.AddAlvoIdentityCookieSignIn(AlvoAdmin.SignInPath); builder.Services.AddAlvoAdmin(admin => builder.Configuration.GetSection(AlvoAdmin.ConfigurationSection).Bind(admin)); builder.Services.AddAlvoAi(); builder.Services.AddScoped(); var app = builder.Build(); app.MapStaticAssets(); app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); app.MapAlvoHealth(); app.MapAlvoDataApi(); app.MapAlvoAdmin(); app.Run(); // The one port between the dashboard and the identity package: the signed-in operator becomes the caller Alvo judges. sealed class AdminCallerResolver(IServiceProvider services) : IAlvoAdminCallerResolver { public ValueTask ResolveAsync(ClaimsPrincipal signedIn, CancellationToken cancellationToken) { var subject = signedIn.FindFirstValue(ClaimTypes.NameIdentifier); if (subject is not { Length: > 0 }) { return ValueTask.FromResult(null); } var resolver = services.GetRequiredKeyedService(AlvoIdentity.ResolverKey); return resolver.ResolveAsync(subject, requestedTenant: null, cancellationToken); } } ``` The dashboard references neither the identity package nor the core, so the host fills the one port between them, `IAlvoAdminCallerResolver`, which turns the signed-in operator into the caller Alvo authorizes; over `MMLib.Alvo.Identity` it asks the identity package's own resolver, registered under `AlvoIdentity.ResolverKey`. `MapAlvoAdmin` maps the dashboard's components and nothing else; the middleware before it is yours to order. The standalone image's [`AlvoHost.cs`](https://github.com/Burgyn/MMLib.Alvo/blob/main/src/MMLib.Alvo.Host/AlvoHost.cs) is the complete composition. Two pieces stay with the host. The sign-in, sign-out and set-password screens post to `AlvoAdmin.SignInEndpoint`, `AlvoAdmin.SignOutEndpoint` and `AlvoAdmin.SetPasswordEndpoint`, and no package maps those: map them over `AlvoSignIn`, validating the antiforgery token in each, as the standalone host's [`AlvoAdminSignIn.cs`](https://github.com/Burgyn/MMLib.Alvo/blob/main/src/MMLib.Alvo.Host/Internal/AlvoAdminSignIn.cs) does. A host with its own users skips `MMLib.Alvo.Identity` and implements `IAlvoAdminCallerResolver` over its own store instead. The packages' entry points are in the C# reference: [`MMLib.Alvo.Admin`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-admin/) and [`MMLib.Alvo.Identity`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-identity/). ## What your host still owns Alvo adds no authentication, authorization or routing middleware on your behalf. Three things stay yours: - **Authentication of your own users.** Alvo's generated API authenticates its own API keys. Your app's cookie or token users reach the same data through your own endpoints, which pass Alvo the caller and let the descriptor's rules decide: [Use your own authentication](https://alvo.burgyn.online/guides/own-authentication/). - **Error rendering.** The code above deliberately does not call `AddAlvoProblemDetails()`, because an embedded host owns the shape of its error responses. Without it, Alvo's endpoints still answer their own refusals as problem documents, but three problem types are only produced with it: `unreadable-request`, `internal` and `function-failed` ([Problem types](https://alvo.burgyn.online/reference/problem-types/)). - **Your own endpoints.** They call Alvo in process through `IAlvoData`, with no HTTP round trip and no second authorization model: [Call Alvo from your endpoints](https://alvo.burgyn.online/guides/call-from-endpoints/). :::caution[Not in this build] A host cannot yet publish its own signed-in user to the generated `/api/alvo` routes: they authenticate the API-key header only, so your users go through your own endpoints. Do not point `Alvo:Auth:HeaderName` at `Cookie` to get around it; Alvo refuses that at startup, because it would make every generated route a cross-site request forgery target. The seam is planned for a later phase ([#210](https://github.com/Burgyn/MMLib.Alvo/issues/210)). ::: ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 401 | [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated) | An API key was sent to `/api/alvo` and cannot be used: a wrong secret, or a role the descriptor does not declare. | Use the secret you configured; give the key only roles in `auth.roles` or built in. | every host | If the app stops at startup instead, the message names the option it refused, such as a dev key with no secret, and what to set. ## Reference - Registration: [`MMLib.Alvo`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo/), [`MMLib.Alvo.Data.Sqlite`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-data-sqlite/), [`MMLib.Alvo.Data.PostgreSql`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-data-postgresql/). - Configuration: [`Alvo:Auth`, `Alvo:Api` and every other key](https://alvo.burgyn.online/reference/configuration/). - The rules every extending package follows: [`docs/architecture/extensibility.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/extensibility.md). ## Next **[Call Alvo from your endpoints](https://alvo.burgyn.online/guides/call-from-endpoints/)**: read and write Alvo's data from your own minimal-API routes, under your users' identity and the descriptor's rules. --- # What works today Source: https://alvo.burgyn.online/start-here/what-works-today/ This page says plainly what the current build does, so you can decide whether it does enough for you. The [Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/) page is the authority: it is generated from the running framework's own answer, the same one the dashboard and the schema assistant read. ## Status Alvo is **pre-v0.1**. The standalone image is published as `ghcr.io/burgyn/alvo:edge`, built from `main`, and runs with one downloaded compose file ([Quick start](https://alvo.burgyn.online/start-here/quick-start/)). No NuGet package is published yet: embedded, you reference the projects from a clone of the repository ([Embed in ASP.NET Core](https://alvo.burgyn.online/start-here/embed/)). The descriptor format and the APIs may still change before v0.1, the first tagged release. [Roadmap and status](https://alvo.burgyn.online/project/roadmap/) lists what may change and what is stable. What you can use today: - The standalone host over PostgreSQL or SQLite, driven by one descriptor file. - The embedded mode: the same engine inside your ASP.NET Core app, extended with your own C# functions and endpoints. - A generated REST API per descriptor, with its own OpenAPI document, filtering, sorting, keyset paging, batches, optimistic concurrency and idempotent writes. - Access rules, before-hooks, after-hooks that send e-mail and deliver webhooks, computed fields, rollups, audit columns, indexes and multi-tenancy. - The Management API, with dry runs, revisions and rollback, and the admin dashboard with its schema assistant. ## Honoured, warned, refused Every block of the descriptor format except `branding` falls into one of three groups. `branding` is accepted, but nothing renders it yet and nothing warns about it. Apart from that, Alvo never accepts a key and quietly does nothing with it without saying so. - **Honoured** blocks work as documented: `entities` (with their fields, rules, hooks, computed fields, rollups and indexes), `auth`, `tenancy`, `access` and `formats`. - **Declared but not run in this build**: the descriptor applies, and the dashboard and the capabilities answer warn that nothing runs. That covers `dynamicEntities`, `automation`, `functions`, sign-in providers other than `local`, `entity.storage: dynamic`, `entity.realtime`, and the parts of `templates` and `webhooks` that only automation would use. Webhook deliveries from after-hooks are made, but not signed yet. - **Refused at apply**: the descriptor is rejected with a reason and a fix, because accepting it would silently give you something other than what it says. Examples: a field's `validation` expression, a `$cel` default, `softDelete`, a rollup's `where` filter, JSONata transformations and the `function`, `http.call` and `entity.update` hook actions. **Dynamic entities**, record types your end users define at runtime in one shared store, are **planned** for a later phase: [Dynamic entities (planned)](https://alvo.burgyn.online/concepts/dynamic-entities/). ## Limits Every size and count the API enforces is listed, with its value and how to change it, in [Limits and budgets](https://alvo.burgyn.online/reference/limits/). The three a first user meets: the page size of a list (a default, and a ceiling a larger `limit` is refused above, never silently capped), the size of a request body, and the number of rows in one batch. ## Performance The published numbers, measured on 2 September 2026 over real PostgreSQL 16 with 200 000 rows, on an Apple M-series laptop with the load generator on the same machine: | Request | p95 | |---|---| | Read one row by id | 5.28 ms | | A filtered, sorted list on an indexed column, over 100 000 rows | 15.61 ms | | Create a row | 12.27 ms | They describe that machine, not a throughput claim; the full table, how it was measured and how to reproduce it are in [`docs/performance.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/performance.md). ## Security posture - **Default-deny.** An entity operation without a rule is refused for everyone, and the Management API admits nobody but the bootstrap administrator until the descriptor's `access` block grants a level. - **Rules filter in the database.** For `list`, `get`, `update` and `delete`, a rule is compiled to a parameterized SQL predicate in the statement that reads or changes the rows, never a check on rows already loaded. On a `create`, and for the check on the row an `update` stores, the same rule is evaluated in memory over that row, inside the write's transaction. [CEL](https://alvo.burgyn.online/concepts/cel/#how-a-rule-becomes-sql) has the table. - **Hooks fail closed.** A before-hook runs inside the write's transaction; if a function in it fails, nothing is written. - **No default credential.** The image ships no API key and no administrator password; each comes from your configuration. The whole model is in [Security model](https://alvo.burgyn.online/concepts/security-model/). --- # Entities and fields Source: https://alvo.burgyn.online/guides/entities-and-fields/ :::tip[What you will achieve] A `customers` entity whose fields check their own values, with one field only administrators may write and one only they may read, and a `tickets` entity that points at a customer. About ten minutes, starting from the stack in [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). ::: ## Before you start - The stack from [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/), 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`). The descriptors on this page declare both roles. The responses below were captured from a real host when the site was built, so your ids will differ. ## 1. Add an entity An entity lives at `/entities/` and each field at `/entities//fields/`. Both names are lower-case snake_case. Every field has a `type`; its other keys constrain the value. 1. Start the stack over this page's first descriptor. The first command **deletes the stack's database**, so the descriptor starts from an empty one: ```sh docker compose down --volumes curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/entities-and-fields/01-basic.alvo.json docker compose up --wait --wait-timeout 90 ``` 2. This is the descriptor the stack now serves: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "providers": ["local"], "roles": ["admin", "agent"] }, "entities": { "customers": { "fields": { "name": { "type": "string", "required": true, "maxLength": 120 }, "email": { "type": "string", "format": "email", "required": true, "unique": true }, "phone": { "type": "string", "maxLength": 20 }, "tier": { "type": "enum", "values": ["standard", "priority"], "default": "standard" }, "credit_limit": { "type": "decimal", "precision": 10, "scale": 2, "readOnly": "!('admin' in @user.roles)" }, "internal_note": { "type": "text", "hidden": "!('admin' in @user.roles)" } }, "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", "delete": "'admin' in @user.roles" } } } } ``` `rules` holds one CEL condition per operation. Alvo is default-deny: an entity without `rules` answers nobody, so every new entity needs them. [Access rules](https://alvo.burgyn.online/guides/access-rules/) covers them in depth. 3. Create a customer as `agent`: ```sh curl -sS -X POST http://localhost:8080/api/customers \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Northwind Traders","email":"it@northwind.example","phone":"+421 2 1234 5678"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/customers/e10bc6e6-b284-431f-8f7b-a1e479363c11 { "id": "e10bc6e6-b284-431f-8f7b-a1e479363c11", "credit_limit": null, "email": "it@northwind.example", "name": "Northwind Traders", "phone": "+421 2 1234 5678", "tier": "standard" } ``` `tier` was filled from its `default`, and `credit_limit` is `null`. `internal_note` is missing because it is hidden from this caller; step 3 explains why. ## 2. Constrain the values The API checks every facet before anything is written, and each refusal names the field. - `required` makes the field NOT NULL: a create must carry it, and an update may not set it to `null`. - `maxLength` bounds a `string`, counted in Unicode code points. - `unique` makes the value unique across the entity, and per tenant on a tenant-scoped one. - `default` is a literal of the field's own type, within its facets. It becomes the column's default. 1. Leave out the required `name` and send a phone number longer than 20 characters. Each field gets one violation: ```sh curl -sS -X POST http://localhost:8080/api/customers \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"email":"help@contoso.example","phone":"+421 2 1234 5678 ext. 9012"}' ``` ```http HTTP/1.1 422 Unprocessable Entity Content-Type: application/problem+json { "type": "https://alvo.dev/errors/validation", "title": "Unprocessable Entity", "status": 422, "detail": "A field the entity declares required is missing or null. A value is longer than the 20 characters the field declares.", "violations": [ { "pointer": "/name", "code": "required", "message": "A field the entity declares required is missing or null.", "fixSuggestion": "Supply a value for it. A create must carry every required field; a partial update may omit any field it is not changing, but may not null a required one." }, { "pointer": "/phone", "code": "max-length", "message": "A value is longer than the 20 characters the field declares.", "fixSuggestion": "Shorten it to at most 20 characters. Length is counted in Unicode code points rather than UTF-16 units, so a character outside the Basic Multilingual Plane counts once and not twice. The bound is the column's own width, so a longer value cannot be stored." } ] } ``` 2. Reuse the first customer's email. Only the database knows that another row holds it, so this refusal is a 409, not a 422: ```sh curl -sS -X POST http://localhost:8080/api/customers \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Northwind (duplicate)","email":"it@northwind.example"}' ``` ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json { "type": "https://alvo.dev/errors/conflict", "title": "Conflict", "status": 409, "detail": "This field is declared unique and another record already holds the value sent for it.", "violations": [ { "pointer": "/email", "code": "unique", "message": "This field is declared unique and another record already holds the value sent for it.", "fixSuggestion": "Send a value no other record holds, or change the record that holds it." } ] } ``` Branch on a violation's `code` (`required`, `max-length`, `unique`), never on its `message`. ## 3. Decide per caller: `readOnly` and `hidden` `readOnly` keeps a caller from writing a field, and `hidden` keeps it out of every response. Each is `true`, or a CEL condition over `@user` and `@tenant` that decides per caller; the condition may not read the row's own fields. In this descriptor, `credit_limit` is read-only and `internal_note` is hidden for everyone who is not an administrator. 1. An agent who sends `credit_limit` is refused, not silently ignored: ```sh curl -sS -X POST http://localhost:8080/api/customers \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Contoso","email":"help@contoso.example","credit_limit":5000}' ``` ```http HTTP/1.1 422 Unprocessable Entity Content-Type: application/problem+json { "type": "https://alvo.dev/errors/validation", "title": "Unprocessable Entity", "status": 422, "detail": "The request writes a field this caller may read but not change.", "violations": [ { "pointer": "/credit_limit", "code": "read-only-field", "message": "The request writes a field this caller may read but not change.", "fixSuggestion": "Remove the field from the request body. It is read-only for your roles, so no value you send can be stored — which is why this is refused rather than ignored." } ] } ``` 2. An administrator may write both fields and reads both back: ```sh curl -sS -X POST http://localhost:8080/api/customers \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Contoso","email":"help@contoso.example","credit_limit":5000,"internal_note":"Pays late; call before renewing."}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/customers/1352eb30-09fc-417c-84bf-91cefa256052 { "id": "1352eb30-09fc-417c-84bf-91cefa256052", "credit_limit": 5000.0, "internal_note": "Pays late; call before renewing." } ``` 3. The agent reads the same customer: `credit_limit` is there, `internal_note` is not. ```sh curl -sS -X GET http://localhost:8080/api/customers/1352eb30-09fc-417c-84bf-91cefa256052 \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "id": "1352eb30-09fc-417c-84bf-91cefa256052", "credit_limit": 5000.0, "email": "help@contoso.example", "name": "Contoso", "phone": null, "tier": "standard" } ``` `hidden` restricts reading only: an agent may still write `internal_note`. A `required` field that is also `"readOnly": true` could never be created, so Alvo refuses that pair at apply unless a literal `default` supplies the value. ## 4. Link entities with a reference A `ref` field holds the id of a row in another entity. `entity` names the target, and `onDelete` says what deleting the target does to this row. 1. Add a `tickets` entity whose `customer_id` points at `customers`: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "providers": ["local"], "roles": ["admin", "agent"] }, "entities": { "customers": { "fields": { "name": { "type": "string", "required": true, "maxLength": 120 }, "email": { "type": "string", "format": "email", "required": true, "unique": true }, "phone": { "type": "string", "maxLength": 20 }, "tier": { "type": "enum", "values": ["standard", "priority"], "default": "standard" }, "credit_limit": { "type": "decimal", "precision": 10, "scale": 2, "readOnly": "!('admin' in @user.roles)" }, "internal_note": { "type": "text", "hidden": "!('admin' in @user.roles)" } }, "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", "delete": "'admin' in @user.roles" } }, "tickets": { "fields": { "title": { "type": "string", "required": true, "maxLength": 120 }, "customer_id": { "type": "ref", "entity": "customers", "required": true, "onDelete": "restrict" }, "assignee_id": { "type": "ref", "entity": "users", "index": true } }, "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", "delete": "'admin' in @user.roles" } } } } ``` `restrict` is the default for `onDelete`; it is written out here so you can see it. `assignee_id` points at `users`, the built-in auth entity, and holds a user's id. 2. Apply it by recreating the `alvo` container. Adding an entity discards nothing, so the restart applies it. ([Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/) covers every way to apply a change.) ```sh curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/entities-and-fields/02-ref.alvo.json docker compose up -d --wait --force-recreate alvo ``` 3. Create a customer under an id you choose (a `PUT` to an id that does not exist yet creates the row), then a ticket for that customer: ```sh curl -sS -X PUT http://localhost:8080/api/customers/0b7f3c1e-5a2d-4e8b-9c6f-1d2e3f4a5b6c \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Northwind Traders","email":"it@northwind.example"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/customers/0b7f3c1e-5a2d-4e8b-9c6f-1d2e3f4a5b6c { "id": "0b7f3c1e-5a2d-4e8b-9c6f-1d2e3f4a5b6c", "name": "Northwind Traders" } ``` ```sh 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":"VPN drops every hour","customer_id":"0b7f3c1e-5a2d-4e8b-9c6f-1d2e3f4a5b6c"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/tickets/0c984400-66b2-4f6a-addf-4fe3c7440500 { "id": "0c984400-66b2-4f6a-addf-4fe3c7440500", "assignee_id": null, "customer_id": "0b7f3c1e-5a2d-4e8b-9c6f-1d2e3f4a5b6c", "title": "VPN drops every hour" } ``` 4. A reference to a row that does not exist is refused. So is a reference to a row you cannot read, and the two refusals are identical on purpose: ```sh 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 offline","customer_id":"9a8b7c6d-0000-4000-8000-000000000000"}' ``` ```http HTTP/1.1 422 Unprocessable Entity Content-Type: application/problem+json { "type": "https://alvo.dev/errors/validation", "title": "Unprocessable Entity", "status": 422, "detail": "A reference names a row that could not be resolved.", "violations": [ { "pointer": "/customer_id", "code": "unresolved-reference", "message": "A reference names a row that could not be resolved.", "fixSuggestion": "Reference a row of the target entity that exists and that you can read. A row you cannot read is indistinguishable from one that does not exist, deliberately." } ] } ``` 5. Delete the customer while a ticket still points at it, and `restrict` refuses: ```sh curl -sS -X DELETE http://localhost:8080/api/customers/0b7f3c1e-5a2d-4e8b-9c6f-1d2e3f4a5b6c \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" ``` ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json { "type": "https://alvo.dev/errors/conflict", "title": "Conflict", "status": 409, "detail": "Other records still reference this record, so it cannot be removed. Delete those records, or point them at something else, and retry.", "violations": [ { "pointer": "", "code": "referenced", "message": "Other records still reference this record, so it cannot be removed. Delete those records, or point them at something else, and retry.", "fixSuggestion": "Delete the records that reference this one, or point them at something else, then retry." } ] } ``` The refusal names no entity, because the records that point here may be data the caller cannot read. ## How it works Each entity becomes a table, each field a column, and each `ref` to a declared entity a foreign key. The API checks the facets it can before the write. `unique` and `restrict` are enforced by the database, and Alvo translates their refusals into the same problem shape. [The descriptor](https://alvo.burgyn.online/concepts/descriptor/) explains the model. ## Options and variations A field's `type` decides which facets it takes. A facet on any other type is refused at apply. | `type` | Holds | Facets | |---|---|---| | `string` | bounded text | [`maxLength`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.maxLength), [`format`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.format) | | `text` | unbounded prose | none | | `integer` | a whole number | none | | `decimal` | an exact number | [`precision`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.precision) (all digits) and [`scale`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.scale) (digits after the point), both required | | `boolean` | `true` or `false` | none | | `date`, `datetime` | a day, an instant | none | | `uuid` | an id | none | | `json` | any JSON value | none | | `enum` | one value of a fixed list | [`values`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.values), required | | `ref` | the id of a row in another entity | [`entity`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.entity), required; [`onDelete`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.onDelete): `restrict`, `cascade` or `setNull` | Every type also takes [`required`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.required), [`unique`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.unique), [`default`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.default), [`hidden`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.hidden), [`readOnly`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.readOnly), [`index`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.index) and [`description`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.description). **Formats.** A `string` field's `format` is a built-in (`email`, `uri` or `phone`) or the name of a format you declare under [`formats`](https://alvo.burgyn.online/reference/descriptor/formats/) with a `pattern` and a `description`. A name that is neither is refused at apply. **References to `users`.** A `ref` to the built-in `users` entity gets no foreign key, no `onDelete` behaviour and no index of its own. Leave `onDelete` off it, and add `"index": true` if callers filter by it, as `assignee_id` does. **Reserved names.** No field may be called `order`, `limit`, `offset`, `after`, `select`, `or`, `and` or `not`, because the query string uses those names. No entity may be called `users`. The columns that the `audit` and `tenancy` traits add (`created_at`, `tenant_id` and the rest) may not be declared on an entity that has the trait; see [`entities`](https://alvo.burgyn.online/reference/descriptor/entities/). **Renames.** Renaming a field or an entity without losing its data takes `renamedFrom`; see [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/#5-rename-without-losing-data). :::caution[Not in this build] Three keys the schema declares are refused when the descriptor is applied ([Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/#refused-at-apply)): - an entity's `softDelete`: a delete would remove the row outright. Remove the key. - a field's `validation`: the expression is not evaluated. Use a facet (`maxLength`, `precision` and `scale`, enum `values`, a `format`) or a [before-hook](https://alvo.burgyn.online/guides/before-hooks/). - a `default` written as `{"$cel": "…"}`: only a literal default is honoured. Send the value on create instead. ::: ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 409 | [`conflict`](https://alvo.burgyn.online/reference/problem-types/#conflict) | Another row already holds a `unique` value (code `unique`), or a `restrict` reference refuses a delete (code `referenced`). | Send a different value, or delete or repoint the rows that reference this one. | every host | | 422 | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | The body breaks a facet (`required`, `max-length`, `enum-value`, `format`, `precision`, `scale`), writes a field that is read-only for the caller (`read-only-field`), references a row that cannot be resolved (`unresolved-reference`), names an undeclared field (`unknown-field`), sends a value the type cannot hold (`invalid-value`), cannot create because a required field is read-only for this caller (`read-only-required-field`), or a format check timed out (`format-not-evaluated`, retry). | Follow each violation's `pointer` and `fixSuggestion`. | every host | If the container does not come back after you change the descriptor, the new version was refused when it was applied: a facet on the wrong type, a reserved name, one of the keys above. The reason is at the end of `docker compose logs alvo`; see [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/#what-can-go-wrong). In this build that refusal ends the process with exit code 139 instead of 78 ([#340](https://github.com/Burgyn/MMLib.Alvo/issues/340)). ## Reference - Descriptor keys: [`entities`](https://alvo.burgyn.online/reference/descriptor/entities/), [fields](https://alvo.burgyn.online/reference/descriptor/entities-fields/), [`formats`](https://alvo.burgyn.online/reference/descriptor/formats/), [rules](https://alvo.burgyn.online/reference/descriptor/entities-rules/). - Problem types: [`conflict`](https://alvo.burgyn.online/reference/problem-types/#conflict), [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation). - How a database constraint becomes a 409: [`docs/architecture/data-api.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#how-a-database-constraint-violation-reaches-the-caller-138-fixed). ## Next **[Computed fields and rollups](https://alvo.burgyn.online/guides/computed-and-rollups/)**: derive a value from the row's own fields, or from the rows that point at it. --- # Access rules Source: https://alvo.burgyn.online/guides/access-rules/ :::tip[What you will achieve] Queues that only administrators manage, tickets that each agent sees only when they filed them, and a clear rule for telling a refusal from an empty page. About fifteen minutes, starting from the stack in [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). ::: ## Before you start - The stack from [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/), 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`). [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/) explains where roles come from. The responses below were captured from a real host when the site was built, so your ids will differ. ## 1. Grant each operation by role An entity's `rules` hold one CEL condition per operation: `list`, `get`, `create`, `update` and `delete`. **An operation without a rule is denied to everyone**, administrators included. A rule reads the caller as `@user.id` and `@user.roles`, the caller's tenant as `@tenant.id`, and the row's own fields by name. :::caution[Rules also judge callers without a key] A request without a key is judged by the same rules, as a caller who holds only the `anon` role and has no `@user.id`. A rule that tests neither a role nor `@user.id`, such as `status == 'open'`, admits that caller too. Start every rule with `'authenticated' in @user.roles` (or a narrower role) unless the data is meant to be public. ::: 1. Start the stack over this page's first descriptor. The first command **deletes the stack's database**: ```sh docker compose down --volumes curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/access-rules/01-roles.alvo.json docker compose up --wait --wait-timeout 90 ``` 2. Everyone signed in reads queues, and only an administrator writes them. Tickets have no `delete` rule: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "roles": ["admin", "agent"] }, "entities": { "queues": { "fields": { "name": { "type": "string", "required": true, "unique": true, "maxLength": 60 } }, "rules": { "list": "'authenticated' in @user.roles", "get": "'authenticated' in @user.roles", "create": "'admin' in @user.roles", "update": "'admin' in @user.roles", "delete": "'admin' in @user.roles" } }, "tickets": { "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" } } } } ``` A rule is the bare CEL expression as one JSON string. Single quotes go only around a text value inside it, such as a role name. 3. An agent tries to create a queue. `create` checks the row being written, and this one fails the rule: ```sh curl -sS -X POST http://localhost:8080/api/queues \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Hardware"}' ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json { "type": "https://alvo.dev/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "The write was rejected by policy." } ``` 4. An administrator creates it, and the agent can list it: ```sh curl -sS -X POST http://localhost:8080/api/queues \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Hardware"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/queues/9b099d24-8852-4890-b840-45231e486f85 { "id": "9b099d24-8852-4890-b840-45231e486f85", "name": "Hardware" } ``` ```sh curl -sS -X GET http://localhost:8080/api/queues \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "id": "9b099d24-8852-4890-b840-45231e486f85", "name": "Hardware" } ], "next": null, "count": null } ``` 5. The agent tries to delete it. `delete` filters the rows a caller can reach, so the queue is simply not there for them, a **404, not a 403**: ```sh curl -sS -X DELETE http://localhost:8080/api/queues/9b099d24-8852-4890-b840-45231e486f85 \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" ``` ```http HTTP/1.1 404 Not Found Content-Type: application/problem+json { "type": "https://alvo.dev/errors/not-found", "title": "Not Found", "status": 404, "detail": "The requested record was not found." } ``` 6. Tickets have no `delete` rule at all. An agent files a ticket, and even an administrator is refused its delete, with a 403 that says why: ```sh 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"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/tickets/834a1c29-a8a6-400d-978b-afeb13304616 { "id": "834a1c29-a8a6-400d-978b-afeb13304616", "status": "open", "title": "Printer on fire" } ``` ```sh curl -sS -X DELETE http://localhost:8080/api/tickets/834a1c29-a8a6-400d-978b-afeb13304616 \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json { "type": "https://alvo.dev/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "No policy allows 'delete' on this entity." } ``` The five rules follow PostgreSQL row-level security. `list`, `get` and `delete` are row filters (`USING`): a caller they exclude sees fewer rows or none. `create` checks the row being written (`WITH CHECK`). `update` does both with the same condition: the row before and the row after must pass. ## 2. Let callers reach only their own rows The ownership pattern compares a field that holds a user's id with `@user.id`. With `"audit": true` the framework writes `created_by` on every create, and a caller cannot set it, so it is a field nobody can forge. 1. Turn on `audit` and make three of the rules ownership rules. Administrators keep reaching every row, and the `access` block lets them use the Management API in [section 5](#5-test-a-rule-with-policysimulate): ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "roles": ["admin", "agent"] }, "access": { "developer": "'admin' in @user.roles" }, "entities": { "queues": { "fields": { "name": { "type": "string", "required": true, "unique": true, "maxLength": 60 } }, "rules": { "list": "'authenticated' in @user.roles", "get": "'authenticated' in @user.roles", "create": "'admin' in @user.roles", "update": "'admin' in @user.roles", "delete": "'admin' in @user.roles" } }, "tickets": { "audit": true, "fields": { "title": { "type": "string", "required": true, "maxLength": 120 }, "status": { "type": "enum", "values": ["open", "closed"], "default": "open" } }, "rules": { "list": "created_by == @user.id || 'admin' in @user.roles", "get": "created_by == @user.id || 'admin' in @user.roles", "create": "'agent' in @user.roles || 'admin' in @user.roles", "update": "created_by == @user.id || 'admin' in @user.roles" } } } } ``` 2. Apply it by recreating the `alvo` container ([Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/) covers the other ways): ```sh curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/access-rules/02-ownership.alvo.json docker compose up -d --wait --force-recreate alvo ``` 3. The administrator files one ticket and the agent files another: ```sh curl -sS -X POST http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"title":"Renew the TLS certificate"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155107691040" Location: /api/tickets/4f208aca-bf3a-4078-86ba-06c7ffce237c { "id": "4f208aca-bf3a-4078-86ba-06c7ffce237c", "title": "Renew the TLS certificate", "created_by": "8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02" } ``` ```sh 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"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155108192080" Location: /api/tickets/8c348efb-8b02-4bdc-823f-304d01ee23bc { "id": "8c348efb-8b02-4bdc-823f-304d01ee23bc", "title": "Printer on fire", "created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01" } ``` 4. The agent's list holds only the agent's ticket. The administrator's holds both: ```sh curl -sS -X GET 'http://localhost:8080/api/tickets?select=id,title,created_by' \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "id": "8c348efb-8b02-4bdc-823f-304d01ee23bc", "title": "Printer on fire", "created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01" } ], "next": null, "count": null } ``` ```sh curl -sS -X GET 'http://localhost:8080/api/tickets?select=id,title,created_by' \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "id": "4f208aca-bf3a-4078-86ba-06c7ffce237c", "title": "Renew the TLS certificate", "created_by": "8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02" }, { "id": "8c348efb-8b02-4bdc-823f-304d01ee23bc", "title": "Printer on fire", "created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01" } ], "next": null, "count": null } ``` 5. The agent asks for the administrator's ticket by id. A row the rule excludes is answered exactly like a row that does not exist: ```sh curl -sS -X GET http://localhost:8080/api/tickets/4f208aca-bf3a-4078-86ba-06c7ffce237c \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" ``` ```http HTTP/1.1 404 Not Found Content-Type: application/problem+json { "type": "https://alvo.dev/errors/not-found", "title": "Not Found", "status": 404, "detail": "The requested record was not found." } ``` To let a caller own rows someone else created, use a field that refers to `users`, such as `"assignee_id": { "type": "ref", "entity": "users" }`, and write the rule as `assignee_id == @user.id`. A `create` rule of the same shape stops a caller from creating a row in somebody else's name. **Role names are checked when the descriptor is applied.** Each role a rule tests must be declared in [`auth.roles`](https://alvo.burgyn.online/reference/descriptor/auth/#auth.roles) or be built in (`anon`, `authenticated`, `admin`). A typo such as `'amdin' in @user.roles` would otherwise match nobody, so the apply refuses it and suggests the role you meant. ## 3. Know when Alvo answers 403 A rule that excludes you is a filter, not a refusal. Expecting a 403 where Alvo answers 200 or 404 is the most common surprise in the API, so here is the whole list: | What happened | Answer | |---|---| | The `list` rule excludes some or all rows | 200, with fewer rows or an empty page | | The `get`, `update` or `delete` rule excludes the row | 404 `not-found`, the same as a row that does not exist | | The operation has no rule | 403 `forbidden`: *"No policy allows '…' on this entity."* | | The row a `create` writes fails the `create` rule | 403 `forbidden`: *"The write was rejected by policy."* | | The rule reads `@user.id` and the caller sent no key | 403 `forbidden` | | The entity is tenant-scoped and the caller has no tenant, or the rule reads `@tenant.id` and the caller has none | 403 `forbidden`; see [Multi-tenancy](https://alvo.burgyn.online/guides/multi-tenancy/) | | The key's scopes do not cover the operation | 403 `out-of-scope`; see [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/#3-narrow-a-key-with-scopes) | So if you wrote a rule and a caller gets an empty page, it is your rule. If a caller gets a 403 on a read, it is one of the rows above and not the condition you wrote. A request without a key, against the ownership rules: ```sh curl -sS -X GET http://localhost:8080/api/tickets ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json { "type": "https://alvo.dev/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "The caller has no identity, and the policy for this operation reads one." } ``` [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) shows how a client should branch on these. ## 4. See how a rule becomes SQL A rule is compiled once, when the descriptor is applied, into a SQL predicate with bound parameters, and every list carries it in its `WHERE` clause. That is why a rule holds for a page of ten rows and for a filter that matches millions. On PostgreSQL, `owner_id == @user.id` becomes: ```txt Rule: owner_id == @user.id, Sql: COALESCE("owner_id" = @alvo_u0, FALSE), Parameters: [ alvo_u0:Guid ] ``` The `COALESCE(…, FALSE)` (`COALESCE(…, 0)` on SQLite) makes a comparison with `null` false rather than unknown, on every engine. Mind the negation: `!(owner_id == @user.id)` is true for a row whose `owner_id` is `null`, so a rule written as "everyone but the owner" also admits rows that have no owner. ## 5. Test a rule with `policy/simulate` The Management API answers what the policy engine would decide for any caller, without a request as that caller. It needs the `viewer` access level, which the `access` block gives administrators. 1. Ask what the agent may `get`. The answer is allowed, with the row filter that will apply: ```sh curl -sS -X POST http://localhost:8080/management/projects/help-desk/policy/simulate \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"entity":"tickets","operation":"get","caller":{"user":"3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01","roles":["agent","authenticated"]}}' ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "allowed": true, "denyReason": null, "using": "created_by == @user.id || 'admin' in @user.roles", "withCheck": null, "tenantScope": null, "hiddenFields": [], "readOnlyFields": [] } ``` 2. Ask about `delete`, which has no rule: ```sh curl -sS -X POST http://localhost:8080/management/projects/help-desk/policy/simulate \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"entity":"tickets","operation":"delete","caller":{"user":"3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01","roles":["agent","authenticated"]}}' ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "allowed": false, "denyReason": "No policy allows 'delete' on this entity.", "using": null, "withCheck": null, "tenantScope": null, "hiddenFields": [], "readOnlyFields": [] } ``` `allowed` says the engine resolved a policy, not that the caller will see rows: a caller no rule admits still gets `true`, with a `using` that none of their rows satisfies. The simulation takes no row id; to check one row, read it through the Data API with that caller's key. The dashboard's rules screen runs the same simulation. ## Options and variations | Pattern | Rule | Where it fits | |---|---|---| | Role gate | `'admin' in @user.roles` | Any operation. | | Anyone signed in | `'authenticated' in @user.roles` | Reference data every caller reads. | | Creator only | `created_by == @user.id` | `list`, `get`, `update`, `delete` on an entity with `audit`. | | Assigned user | `assignee_id == @user.id` | A `ref` to `users`; also as the `create` rule. | | Public to everyone, including no key | `'anon' in @user.roles \|\| 'authenticated' in @user.roles` | Data meant for the open internet; `list` and `get` only. | | Public or own | `is_public \|\| owner_id == @user.id` | A boolean field read as a condition. Because it reads `@user.id`, a caller without a key is refused with a 403 even for public rows. | | Combined | `created_by == @user.id \|\| 'admin' in @user.roles` | Keep earlier grants by adding a clause with `\|\|`. | Each rule's slot is in the reference: [`list`](https://alvo.burgyn.online/reference/descriptor/entities-rules/#entities.rules.list), [`get`](https://alvo.burgyn.online/reference/descriptor/entities-rules/#entities.rules.get), [`create`](https://alvo.burgyn.online/reference/descriptor/entities-rules/#entities.rules.create), [`update`](https://alvo.burgyn.online/reference/descriptor/entities-rules/#entities.rules.update) and [`delete`](https://alvo.burgyn.online/reference/descriptor/entities-rules/#entities.rules.delete). A rule compares with `==`, `!=`, `<`, `<=`, `>`, `>=`, tests roles with `in`, tests presence with `has(field)`, and combines with `&&`, `||` and `!`. `<`, `<=`, `>`, `>=` take numbers and dates, not text; compare with `null` through `has(field)`, not `== null`. A rule has no arithmetic, no `old.` or `new.` and no `changed()`: those belong to [before-hooks](https://alvo.burgyn.online/guides/before-hooks/). The same expression language decides per caller whether a field is `hidden` or `readOnly`; see [Entities and fields](https://alvo.burgyn.online/guides/entities-and-fields/#3-decide-per-caller-readonly-and-hidden). :::caution[Not in this build] A rule admits no function call, not even a built-in one such as `trim`: a rule is a SQL filter, and functions are not translated to SQL yet (the profiles are in the [CEL function catalog](https://alvo.burgyn.online/reference/cel-functions/)). To filter by a normalised value, store it with a before-hook `mutate` and compare that field. A rule also cannot read any attribute of the caller beyond `@user.id`, `@user.roles` and `@tenant.id`. ::: ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | The operation has no rule; the row a `create` writes fails its rule; the caller lacks the identity or tenant the rule reads. | Add the rule, or send a key that carries what the rule reads. | every host | | 403 | [`out-of-scope`](https://alvo.burgyn.online/reference/problem-types/#out-of-scope) | The key's scopes do not cover this entity and operation. | Grant the key the scope. | every host | | 404 | [`not-found`](https://alvo.burgyn.online/reference/problem-types/#not-found) | The `get`, `update` or `delete` rule excludes the row, or the row does not exist. | Check the rule with `policy/simulate`, then the row with the caller's own key. | every host | | 401 | [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated) | The key cannot be used, for example because it holds a role the descriptor does not declare. | See [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/). | every host | A rule Alvo cannot compile is refused when the descriptor is applied, and the container does not come back: an undeclared role, a field the entity does not have, a function call, or a rule wrapped in an extra pair of quotes (`"'author_id == @user.id'"` is one string literal, and the refusal says *Remove the outer quotes*). 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](https://github.com/Burgyn/MMLib.Alvo/issues/340)). ## Reference - Descriptor keys: [rules](https://alvo.burgyn.online/reference/descriptor/entities-rules/), [`auth.roles`](https://alvo.burgyn.online/reference/descriptor/auth/#auth.roles), [`access`](https://alvo.burgyn.online/reference/descriptor/access/), [`audit`](https://alvo.burgyn.online/reference/descriptor/entities/#entities.audit). - Management API: [`SimulatePolicyAsync`](https://alvo.burgyn.online/reference/management-api/). - Problem types: [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), [`out-of-scope`](https://alvo.burgyn.online/reference/problem-types/#out-of-scope), [`not-found`](https://alvo.burgyn.online/reference/problem-types/#not-found), [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated). - Design notes: [the decision procedure](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#the-decision-procedure-the-four-ways-a-caller-actually-gets-403) and [`USING`/`WITH CHECK` per operation](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/cel.md#usingwith-check-per-operation). ## Next **[Multi-tenancy](https://alvo.burgyn.online/guides/multi-tenancy/)**: keep each customer's rows apart in one database. --- # Before-hooks Source: https://alvo.burgyn.online/guides/before-hooks/ :::tip[What you will achieve] A ticket cannot be closed without a resolution, its title is stored trimmed, it gets a slug and a rounded estimate, and closing it stamps the time. About fifteen minutes, starting from the stack in [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). ::: ## Before you start - The stack from [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/), 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](https://alvo.burgyn.online/guides/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 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**: ```sh 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: ```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: ```sh 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"}' ``` ```http 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 } ``` ```sh 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"}' ``` ```http 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: ```sh 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."}' ``` ```http 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. ## 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. 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`: ```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](https://alvo.burgyn.online/guides/apply-and-evolve/) covers the other ways): ```sh 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: ```sh 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}' ``` ```http 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.0, "closed_at": null } ``` 4. Close it. The second `beforeUpdate` hook stamps the time: ```sh 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."}' ``` ```http 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](https://alvo.burgyn.online/guides/computed-and-rollups/) instead. ## 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: ```sh 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"}' ``` ```http 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`. ## 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](https://alvo.burgyn.online/concepts/cel/) explains the profiles. ## Options and variations **What a condition can read**, per hook point: | Hook point | `new.` | `old.` | `changed()` | `mutate` | |---|---|---|---|---| | [`beforeCreate`](https://alvo.burgyn.online/reference/descriptor/entities-hooks/#entities.hooks.beforeCreate) | yes | no | no | yes | | [`beforeUpdate`](https://alvo.burgyn.online/reference/descriptor/entities-hooks/#entities.hooks.beforeUpdate) | yes | yes | yes | yes | | [`beforeDelete`](https://alvo.burgyn.online/reference/descriptor/entities-hooks/#entities.hooks.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](https://alvo.burgyn.online/reference/cel-functions/), and an embedded host can add its own with [Custom CEL functions](https://alvo.burgyn.online/guides/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. :::caution[Not in this build] A custom CEL function an embedded host registers runs synchronously with no time budget, so a slow one holds the write's transaction and its row locks ([#309](https://github.com/Burgyn/MMLib.Alvo/issues/309)). A before-hook cannot call the network, send mail or write another entity; that is what [after-hooks](https://alvo.burgyn.online/guides/after-hooks-and-webhooks/) are for. A hook that runs on a schedule is `automation`, which this build parses and does not run ([Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/#declared-but-not-run-in-this-build)). ::: ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#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`](https://alvo.burgyn.online/reference/problem-types/#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](https://github.com/Burgyn/MMLib.Alvo/issues/340)). :::tip Branch on the problem `type`, never on `detail`: the slug is the contract, and the sentence is for people. [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) shows how. ::: ## Reference - Descriptor keys: [`hooks`](https://alvo.burgyn.online/reference/descriptor/entities-hooks/#entities.hooks), [`condition`](https://alvo.burgyn.online/reference/descriptor/entities-hooks/#entities.hooks.beforeCreate.condition), [`reject`](https://alvo.burgyn.online/reference/descriptor/entities-hooks/#entities.hooks.beforeCreate.action.reject), [`mutate`](https://alvo.burgyn.online/reference/descriptor/entities-hooks/#entities.hooks.beforeCreate.action.mutate). - Functions: [the CEL function catalog](https://alvo.burgyn.online/reference/cel-functions/). - Problem types: [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), [`function-failed`](https://alvo.burgyn.online/reference/problem-types/#function-failed). - Design notes: [before-hooks](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/events.md#before-hooks) and [the `Mutate` profile](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/cel.md#mutate-the-fourth-profile). ## Next **[After-hooks, events and webhooks](https://alvo.burgyn.online/guides/after-hooks-and-webhooks/)**: react to a committed change, with a webhook or an email. --- # Computed fields and rollups Source: https://alvo.burgyn.online/guides/computed-and-rollups/ :::tip[What you will achieve] Invoice lines that compute their own total, and invoices that sum their lines, count them and add VAT, with no caller able to write any of those numbers. About ten minutes, starting from the stack in [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). ::: ## Before you start - The stack from [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/), run from its `alvo-help-desk` directory with `COMPOSE_FILE` and that page's secrets exported in this shell. This page uses the `admin` key (roles `admin`, `authenticated`). - [Entities and fields](https://alvo.burgyn.online/guides/entities-and-fields/), for fields, facets and references. Choose the lowest rung that expresses the value: a **computed** field for an expression over the same row, a **rollup** for an aggregate over related rows, and a [before-hook](https://alvo.burgyn.online/guides/before-hooks/) for a value decided when the row is written. ## 1. Compute a value from the row A `computed` field is a CEL expression over the other fields of its own row. The database stores it as a generated column and keeps it current, and nobody writes it. 1. Start the stack over this page's first descriptor. The first command **deletes the stack's database**: ```sh docker compose down --volumes curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/computed-and-rollups/01-computed.alvo.json docker compose up --wait --wait-timeout 90 ``` 2. `line_total` multiplies two fields of the same line: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "providers": ["local"], "roles": ["admin", "agent"] }, "entities": { "invoices": { "fields": { "number": { "type": "string", "required": true, "unique": true, "maxLength": 20 }, "vat_total": { "type": "decimal", "precision": 12, "scale": 2, "required": true, "default": 0 } }, "rules": { "list": "'authenticated' in @user.roles", "get": "'authenticated' in @user.roles", "create": "'admin' in @user.roles", "update": "'admin' in @user.roles" } }, "invoice_lines": { "fields": { "invoice_id": { "type": "ref", "entity": "invoices", "required": true, "onDelete": "cascade" }, "description": { "type": "string", "required": true, "maxLength": 200 }, "unit_price": { "type": "decimal", "precision": 10, "scale": 2, "required": true }, "quantity": { "type": "decimal", "precision": 8, "scale": 2, "required": true }, "line_total": { "type": "decimal", "precision": 18, "scale": 2, "computed": "unit_price * quantity" } }, "rules": { "list": "'authenticated' in @user.roles", "get": "'authenticated' in @user.roles", "create": "'admin' in @user.roles", "update": "'admin' in @user.roles", "delete": "'admin' in @user.roles" } } } } ``` A computed field is declared as the type it produces, here a `decimal` with room for the product. 3. Create an invoice under an id you choose, then a line on it. The response carries `line_total`: ```sh curl -sS -X PUT http://localhost:8080/api/invoices/4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"number":"2026-0042","vat_total":11.5}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/invoices/4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d { "id": "4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d", "number": "2026-0042" } ``` ```sh curl -sS -X POST http://localhost:8080/api/invoice_lines \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"invoice_id":"4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d","description":"On-site support, hours","unit_price":12.5,"quantity":4}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/invoice_lines/1a8c100e-cef7-4ae4-ba72-b76498c382a2 { "id": "1a8c100e-cef7-4ae4-ba72-b76498c382a2", "description": "On-site support, hours", "invoice_id": "4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d", "line_total": 50.0, "quantity": 4.0, "unit_price": 12.5 } ``` 4. A body that names `line_total` is refused rather than quietly dropped, so a caller never gets a 201 that reports a different number from the one it sent: ```sh curl -sS -X POST http://localhost:8080/api/invoice_lines \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"invoice_id":"4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d","description":"Travel","unit_price":20,"quantity":1,"line_total":15}' ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json { "type": "https://alvo.dev/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "Field 'line_total' is computed by the database and cannot be written: it is a stored generated column, so the engine itself refuses every write to it. Remove it from the payload — its value follows from the fields the expression reads." } ``` The refusal is a 403 `forbidden`, not a 422 `validation`: the detail names the field to remove. ## 2. Roll up related rows A `rollup` aggregates the rows of another entity that point at this one. `from` names that entity, `op` the aggregate and `field` the child's field. Alvo recomputes it inside the same transaction as every write to a child row, and only Alvo writes it: a body that names a rollup field is refused with the same 403 `forbidden` as one naming a computed field, on every write route and in every batch row. 1. Give each invoice the sum of its line totals, the number of its lines, and a gross total computed over the rollup: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "providers": ["local"], "roles": ["admin", "agent"] }, "entities": { "invoices": { "fields": { "number": { "type": "string", "required": true, "unique": true, "maxLength": 20 }, "vat_total": { "type": "decimal", "precision": 12, "scale": 2, "required": true, "default": 0 }, "net_total": { "type": "decimal", "precision": 18, "scale": 2, "rollup": { "from": "invoice_lines", "op": "sum", "field": "line_total" } }, "line_count": { "type": "integer", "rollup": { "from": "invoice_lines", "op": "count" } }, "gross_total": { "type": "decimal", "precision": 18, "scale": 2, "computed": "net_total + vat_total" } }, "rules": { "list": "'authenticated' in @user.roles", "get": "'authenticated' in @user.roles", "create": "'admin' in @user.roles", "update": "'admin' in @user.roles" } }, "invoice_lines": { "fields": { "invoice_id": { "type": "ref", "entity": "invoices", "required": true, "onDelete": "cascade" }, "description": { "type": "string", "required": true, "maxLength": 200 }, "unit_price": { "type": "decimal", "precision": 10, "scale": 2, "required": true }, "quantity": { "type": "decimal", "precision": 8, "scale": 2, "required": true }, "line_total": { "type": "decimal", "precision": 18, "scale": 2, "computed": "unit_price * quantity" } }, "rules": { "list": "'authenticated' in @user.roles", "get": "'authenticated' in @user.roles", "create": "'admin' in @user.roles", "update": "'admin' in @user.roles", "delete": "'admin' in @user.roles" } } } } ``` `net_total` sums `line_total`, itself a computed field. `gross_total` is computed again, over `net_total`: a computed field may read a rollup of its own row. 2. Apply it by recreating the `alvo` container. Adding fields discards nothing, so the restart applies it ([Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/) covers the other ways): ```sh curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/computed-and-rollups/02-rollup.alvo.json docker compose up -d --wait --force-recreate alvo ``` 3. Create an invoice. Until a line is written for it, every rollup reads `null`, and so does a computed field over one: ```sh curl -sS -X PUT http://localhost:8080/api/invoices/4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"number":"2026-0042","vat_total":11.5}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/invoices/4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d { "id": "4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d", "gross_total": null, "line_count": null, "net_total": null, "number": "2026-0042", "vat_total": 11.5 } ``` 4. Add two lines: ```sh curl -sS -X POST http://localhost:8080/api/invoice_lines \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"invoice_id":"4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d","description":"On-site support, hours","unit_price":12.5,"quantity":4}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/invoice_lines/4daaddc7-9332-4ba6-bd89-db54aa0da26e { "id": "4daaddc7-9332-4ba6-bd89-db54aa0da26e", "unit_price": 12.5, "quantity": 4.0, "line_total": 50.0 } ``` ```sh curl -sS -X POST http://localhost:8080/api/invoice_lines \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"invoice_id":"4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d","description":"Replacement keyboard","unit_price":7.5,"quantity":1}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/invoice_lines/e9b3ecb1-76e6-4586-a261-b1a97e596d56 { "id": "e9b3ecb1-76e6-4586-a261-b1a97e596d56", "unit_price": 7.5, "quantity": 1.0, "line_total": 7.5 } ``` 5. Read the invoice. Each line's write recomputed its totals: ```sh curl -sS -X GET http://localhost:8080/api/invoices/4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "id": "4c1d2b3a-6e5f-4a7b-8c9d-0e1f2a3b4c5d", "gross_total": 69.0, "line_count": 2, "net_total": 57.5, "number": "2026-0042", "vat_total": 11.5 } ``` An update that moves a line to another invoice recomputes both invoices, and deleting a line recomputes its invoice. With no lines left, `count` answers `0` and the other four aggregates answer `null`. :::caution[Known issue in this build] The recompute does not advance the parent's `ETag`, so after a write to a line the invoice's totals are new but its tag is not: an `If-Match` taken before still succeeds, and `If-None-Match` still answers 304. Read the parent again after writing its children before you condition a write on its tag ([#351](https://github.com/Burgyn/MMLib.Alvo/issues/351)). ::: ## How it works The two look alike in the descriptor and work differently underneath. A computed field is part of the table's definition, so the database itself refuses every write to it, from any source. A rollup is maintained by Alvo: it locks the parent row, then recomputes the aggregate from scratch, so concurrent writers cannot lose an update. A write that bypasses Alvo, such as a raw `INSERT` into the child table, leaves a rollup stale. [`docs/architecture/data-path.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-path.md#computed-is-the-engines-column-rollup-is-this-ports-statement-21) has the details. ## Options and variations **Rollup aggregates** ([`op`](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/#entities.fields.rollup.op)): | `op` | Answers | Needs `field` | |---|---|---| | `sum` | the sum of the child field | yes | | `count` | the number of child rows | no | | `avg` | the average of the child field | yes | | `min`, `max` | the smallest or largest value of the child field | yes | When the child entity has more than one `ref` to this one, name the one to aggregate over with [`via`](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/#entities.fields.rollup.via). **What a computed expression may contain.** It is checked when the descriptor is applied, and every refusal says why. Computed fields admit no function call (see the profiles in the [CEL function catalog](https://alvo.burgyn.online/reference/cel-functions/) and [CEL in Alvo](https://alvo.burgyn.online/concepts/cel/)). | Allowed | Refused | |---|---| | Arithmetic `+ - * /` and unary `-` over numeric fields: `unit_price * quantity`, `net_total + vat_total` | A numeric constant: `unit_price * 1.2`. Keep the rate in a field of its own. | | `+` joining two strings: `first_name + ' ' + last_name`, where a text constant is in single quotes | Joining text and a number, `first_name + bikes_count`: there is no conversion. | | An optional field joined only inside the branch its own `has()` guards: `(has(middle_name) ? middle_name : '') + last_name` | An optional field joined directly, or a combined guard such as `has(a) && has(b) ? … : …` | | A ternary whose condition compares two fields of the row, or is `has(f)` or `!has(f)` | A boolean result: a comparison or `has()` may only be a ternary's condition. | | A rollup field of the same row | Another computed field, `@user`, `@tenant`, `now()` or any other function, `old.` or `new.` | A computed field that joins text must be a `string` or `text` field. A `maxLength` on it must hold the longest possible result; omitting `maxLength` is always accepted. :::caution[Not in this build] A rollup's `where`, which would aggregate only some child rows, is refused at apply, because the aggregate would silently include every row ([Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/#refused-at-apply)). Aggregate every child row, or move the distinction into the model, for example a separate child entity. ::: ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | The body names a computed or rollup field. | Remove the field from the body; its value follows from the fields its expression reads, or from the child rows. | every host | | 422 | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | The body breaks a facet of a field the expression reads, such as a missing required `unit_price`. | Follow the violation's `pointer` and `fixSuggestion`. | every host | An expression Alvo cannot store is refused when the descriptor is applied, and the container does not come back. The end of `docker compose logs alvo` names the field and the reason, for example a numeric constant; see [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/#what-can-go-wrong). In this build that refusal ends the process with exit code 139 instead of 78 ([#340](https://github.com/Burgyn/MMLib.Alvo/issues/340)). On SQLite, a computed `decimal` is evaluated as a floating-point number, so `0.1 * 3` reads as `0.30000000000000004` where PostgreSQL answers `0.30` ([#162](https://github.com/Burgyn/MMLib.Alvo/issues/162)). ## Reference - Descriptor keys: [`computed`](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/#entities.fields.computed), [`rollup`](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/#entities.fields.rollup) and its [`from`](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/#entities.fields.rollup.from), [`op`](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/#entities.fields.rollup.op), [`field`](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/#entities.fields.rollup.field) and [`via`](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/#entities.fields.rollup.via). - Problem types: [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation). ## Next **[Indexes and uniqueness](https://alvo.burgyn.online/guides/indexes/)**: speed up the queries you run most, and make a combination of fields unique. --- # Indexes and uniqueness Source: https://alvo.burgyn.online/guides/indexes/ :::tip[What you will achieve] Tickets indexed for "the open tickets of one queue", numbered uniquely within each queue, and filterable by assignee. About five minutes, starting from the stack in [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). ::: ## Before you start - The stack from [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/), 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`). - [Entities and fields](https://alvo.burgyn.online/guides/entities-and-fields/), for fields and `unique`. ## Indexes Alvo creates for you Declare an index only beyond these, which every entity already has: - the primary key `id`; - every field with `"unique": true`; - every `ref` to a declared entity. A `ref` to the built-in `users` entity is **not** indexed by itself. ## 1. Declare the indexes 1. Start the stack over this page's descriptor. The first command **deletes the stack's database**: ```sh docker compose down --volumes curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/indexes/01-indexes.alvo.json docker compose up --wait --wait-timeout 90 ``` 2. The descriptor declares three indexes: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "providers": ["local"], "roles": ["admin", "agent"] }, "entities": { "tickets": { "fields": { "queue": { "type": "enum", "values": ["billing", "hardware", "software"], "required": true }, "number": { "type": "integer", "required": true }, "title": { "type": "string", "required": true, "maxLength": 120 }, "status": { "type": "enum", "values": ["open", "closed"], "default": "open" }, "assignee_id": { "type": "ref", "entity": "users", "index": true } }, "indexes": [ { "fields": ["queue", "status"] }, { "fields": ["queue", "number"], "unique": true } ], "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" } } } } ``` - `"index": true` on `assignee_id` is the one-field form. Use it for a single field, here a `ref` to `users`, which gets no index otherwise. - `{"fields": ["queue", "status"]}` is a composite index. List the fields in query order: callers filter on `queue` first, then on `status`. - `{"fields": ["queue", "number"], "unique": true}` makes the **combination** unique: one ticket per number within each queue. A plain index never refuses a write; it only makes reads faster. When the schema assistant (or any JSON Patch) adds the first index, it must add the whole `indexes` list: an `add` at `/indexes/-` on a list that does not exist yet is refused. ## 2. Number tickets within each queue 1. Ticket 1 in `billing`, then ticket 1 in `hardware`. The same number in two queues is allowed: ```sh 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 '{"queue":"billing","number":1,"title":"Invoice sent twice"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/tickets/66f74c91-8e33-4460-b971-5d6142011be6 { "id": "66f74c91-8e33-4460-b971-5d6142011be6", "queue": "billing", "number": 1, "title": "Invoice sent twice" } ``` ```sh 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 '{"queue":"hardware","number":1,"title":"Laptop will not charge"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/tickets/fb953b71-a719-4363-8596-5ac4cfb7eab0 { "id": "fb953b71-a719-4363-8596-5ac4cfb7eab0", "queue": "hardware", "number": 1, "title": "Laptop will not charge" } ``` 2. A second ticket 1 in `billing` collides with the first: ```sh 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 '{"queue":"billing","number":1,"title":"Refund for March"}' ``` ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json { "type": "https://alvo.dev/errors/conflict", "title": "Conflict", "status": 409, "detail": "This field is declared unique and another record already holds the value sent for it.", "violations": [ { "pointer": "/queue", "code": "unique", "message": "This field is declared unique and another record already holds the value sent for it.", "fixSuggestion": "Send a value no other record holds, or change the record that holds it." }, { "pointer": "/number", "code": "unique", "message": "This field is declared unique and another record already holds the value sent for it.", "fixSuggestion": "Send a value no other record holds, or change the record that holds it." } ] } ``` Each field of the combination gets a violation with the code `unique`. The message is the one a single unique field gets; the pointers name the combination. ## How it works A declared index becomes a database index when the descriptor is applied, and a unique one makes the database refuse a duplicate, which Alvo answers as 409 `conflict`. On a tenant-scoped entity, Alvo puts `tenant_id` first in every unique index, both a field's `unique` and a unique combination, so two tenants may hold the same value and neither learns that the other does. [Multi-tenancy](https://alvo.burgyn.online/guides/multi-tenancy/) covers tenant-scoped entities. ## Options and variations | You want | Declare | |---|---| | One field unique across the entity | the field's [`unique`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.unique) | | A combination unique, such as one number per queue | an entry in [`indexes`](https://alvo.burgyn.online/reference/descriptor/entities-indexes/#entities.indexes) with [`unique`](https://alvo.burgyn.online/reference/descriptor/entities-indexes/#entities.indexes.unique) | | A faster filter on one field | the field's [`index`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.index) | | A faster filter on several fields together | an entry in `indexes` with its [`fields`](https://alvo.burgyn.online/reference/descriptor/entities-indexes/#entities.indexes.fields) in query order | A field's `unique` and a unique combination are different rules: `unique` on both `queue` and `number` would allow one ticket per queue in total. ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 409 | [`conflict`](https://alvo.burgyn.online/reference/problem-types/#conflict) | A write would give two rows the same value of a unique field, or the same combination of a unique index (code `unique`). | Send a value, or a combination, that no other row holds. | every host | **A new unique index cannot be created while duplicate rows exist.** Before you add one to an entity that already holds data, remove or change the rows that share a value. ## Reference - Descriptor keys: [`indexes`](https://alvo.burgyn.online/reference/descriptor/entities-indexes/), [`index`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.index), [`unique`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.unique). - Problem types: [`conflict`](https://alvo.burgyn.online/reference/problem-types/#conflict). ## Next **[Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/)**: change a running backend safely, with dry runs, revisions and rollback. --- # Authentication and API keys Source: https://alvo.burgyn.online/guides/authentication/ :::tip[What you will achieve] A read-only `reader` key beside the stack's keys, and the four answers a request can get because of its credential: served, anonymous, `401 unauthenticated` and `403 out-of-scope`. About ten minutes, starting from the stack in [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). ::: ## Before you start - The stack from [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/), serving `examples/help-desk`, run from its `alvo-help-desk` directory with `COMPOSE_FILE` and that page's secrets exported in this shell. - This page adds a fourth key, `reader` (roles `agent`, `authenticated`; scope `*:read` only), and also uses `agent` and the stack's own `demo` key. The responses below were captured from a real host when the site was built, so your ids will differ. ## 1. Give a caller a key A key is configuration, not data: the host reads a fixed list from `Alvo:Auth:DevKeys`, and each entry names the user the key acts as, the roles it holds and the scopes it may use. This build has no endpoint that issues or revokes keys, so treat it as a development mechanism: the secret lives in the process's configuration, and when it is passed as an environment variable it is readable from a process listing and from `docker inspect`. Until a real issuance path lands ([#36](https://github.com/Burgyn/MMLib.Alvo/issues/36)) these keys are the only way to call a standalone host, so generate every secret, never reuse one across keys or environments, and prefer a configuration source that does not show up in `docker inspect`, such as a mounted file or a secret store. 1. Replace `docker-compose.override.yml` with this one. It keeps everything the first one sets, adds `reader`, and gives the `demo` key a third role, `inspector`, which section 2 uses: ```yaml # Saved as docker-compose.override.yml, it replaces the one from "Run your own descriptor" and keeps everything # that one sets. Key 3, reader, may read every entity and write none: its scopes hold "*:read" only. The quick # start's demo key gets a third role, inspector, as in the repository's own stack; help-desk does not declare it. name: alvo-help-desk services: alvo: environment: Alvo__DescriptorPath: /alvo/descriptor.json Alvo__Auth__DevKeys__0__Roles__2: inspector Alvo__Auth__DevKeys__1__KeyId: agent Alvo__Auth__DevKeys__1__Secret: ${ALVO_AGENT_KEY_SECRET:?set ALVO_AGENT_KEY_SECRET before starting the stack} Alvo__Auth__DevKeys__1__User: 3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01 Alvo__Auth__DevKeys__1__Roles__0: agent Alvo__Auth__DevKeys__1__Roles__1: authenticated Alvo__Auth__DevKeys__1__Scopes__0: "*:read" Alvo__Auth__DevKeys__1__Scopes__1: "*:write" Alvo__Auth__DevKeys__2__KeyId: admin Alvo__Auth__DevKeys__2__Secret: ${ALVO_ADMIN_KEY_SECRET:?set ALVO_ADMIN_KEY_SECRET before starting the stack} Alvo__Auth__DevKeys__2__User: 8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02 Alvo__Auth__DevKeys__2__Roles__0: admin Alvo__Auth__DevKeys__2__Roles__1: authenticated Alvo__Auth__DevKeys__2__Scopes__0: "*:read" Alvo__Auth__DevKeys__2__Scopes__1: "*:write" Alvo__Auth__DevKeys__3__KeyId: reader Alvo__Auth__DevKeys__3__Secret: ${ALVO_READER_KEY_SECRET:?set ALVO_READER_KEY_SECRET before starting the stack} Alvo__Auth__DevKeys__3__User: 5a1d0c3e-2f4b-4a6c-9d8e-7f6a5b4c3d03 Alvo__Auth__DevKeys__3__Roles__0: agent Alvo__Auth__DevKeys__3__Roles__1: authenticated Alvo__Auth__DevKeys__3__Scopes__0: "*:read" volumes: - ./help-desk.alvo.json:/alvo/descriptor.json:ro ``` 2. Download it, generate the new secret and recreate the `alvo` container: ```sh curl -fsSL -o docker-compose.override.yml https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/shell/authentication.compose.override.yml export ALVO_READER_KEY_SECRET="$(openssl rand -hex 16)" docker compose up -d --wait --force-recreate alvo ``` While this override is in place, every compose command reads it, so keep `ALVO_READER_KEY_SECRET` exported too. A secret shorter than 32 characters is refused at startup, and the container does not come back. `openssl rand -hex 16` produces exactly 32. 3. A request carries the key in the `X-Alvo-Api-Key` header, as `.`: ```sh 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"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155168489190" Location: /api/tickets/e476759e-194f-4007-bce3-af7039cdf070 { "id": "e476759e-194f-4007-bce3-af7039cdf070", "title": "Printer on fire", "status": "open" } ``` Every setting of a key: | Setting | What it decides | |---|---| | `KeyId` | The public half of the key. Unique, and without a `.`, because the header is split at the first `.`. | | `Secret` | The private half, at least 32 characters. Generate it; never choose it. | | `User` | The id the caller acts as: what a rule reads as `@user.id`, and what the audit columns record. Set it: a key without one has no identity, so a rule that reads `@user.id` refuses it with a 403 and the audit columns record `null`. | | `Roles` | What a rule tests, as in `'agent' in @user.roles`. Each role must be declared in the descriptor's [`auth.roles`](https://alvo.burgyn.online/reference/descriptor/auth/#auth.roles) or be built in (`anon`, `authenticated`, `admin`). `authenticated` is not added for you: list it. | | `Scopes` | Which entities the key may read or write, as `:`. | | `Tenant` | The one tenant the key acts in; see [Multi-tenancy](https://alvo.burgyn.online/guides/multi-tenancy/). | | `ExpiresAt` | The instant after which the key is refused. Unset, it never expires. | In an environment variable each setting is indexed, as in the override above: `Alvo__Auth__DevKeys__3__Roles__0`. The full list is under [`Alvo:Auth`](https://alvo.burgyn.online/reference/configuration/#alvoauth). ## 2. A request without a usable key Alvo tells three cases apart: no key, a key that cannot be used, and a key that may not do this. 1. **No key is not a 401.** A request without `X-Alvo-Api-Key` is an anonymous caller, who holds only the `anon` role, and the rules decide what it gets. The help-desk `list` rule asks for `authenticated`, so the page is empty, though the ticket from step 1 exists: ```sh curl -sS -X GET http://localhost:8080/api/tickets ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [], "next": null, "count": null } ``` A rule that reads `@user.id` refuses an anonymous caller with 403 `forbidden` instead; see [Access rules](https://alvo.burgyn.online/guides/access-rules/#3-know-when-alvo-answers-403). 2. **A key that cannot be used is a 401.** Here the secret is wrong: ```sh curl -sS -X GET http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: agent.this-is-not-the-secret-of-this-key" ``` ```http HTTP/1.1 401 Unauthorized Content-Type: application/problem+json { "type": "https://alvo.dev/errors/unauthenticated", "title": "Unauthorized", "status": 401, "detail": "The presented API key could not be used. Check the key, whether it has been revoked or has expired, and whether it was issued for the tenant you requested." } ``` 3. The override gave the `demo` key the role `inspector`, which `help-desk` does not declare. One undeclared role is enough for the whole key to authenticate nothing: ```sh curl -sS -X GET http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 401 Unauthorized Content-Type: application/problem+json { "type": "https://alvo.dev/errors/unauthenticated", "title": "Unauthorized", "status": 401, "detail": "The presented API key could not be used. Check the key, whether it has been revoked or has expired, and whether it was issued for the tenant you requested." } ``` The answer is the same, word for word, for an unknown key, a wrong secret, an expired key, a key with an undeclared role or no role at all, and a tenant header the key was not issued for. That is deliberate: a caller learns nothing about which keys exist. The host's configuration is where to look. ## 3. Narrow a key with scopes Scopes belong to the key and are checked before any rule. `read` covers `list` and `get`; `write` covers `create`, `update` and `delete`, and does not include `read`. A key with no scopes may do nothing on the Data API. 1. `reader` holds `*:read` only, so its write is refused before Alvo looks at a rule or a row: ```sh curl -sS -X POST http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: reader.$ALVO_READER_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"title":"Printer on fire"}' ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json { "type": "https://alvo.dev/errors/out-of-scope", "title": "Forbidden", "status": 403, "detail": "The presented API key's scopes do not permit this operation. Grant the key the scope it needs." } ``` 2. The same key reads, within what the rules allow its roles: ```sh curl -sS -X GET http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: reader.$ALVO_READER_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "id": "e476759e-194f-4007-bce3-af7039cdf070", "body": null, "created_at": "2026-10-11T11:38:36.848919+00:00", "created_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01", "estimate_cost": null, "estimate_hours": null, "hourly_rate": null, "priority": "normal", "status": "open", "title": "Printer on fire", "updated_at": "2026-10-11T11:38:36.848919+00:00", "updated_by": "3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01" } ], "next": null, "count": null } ``` A request has to pass both the key's scopes and the entity's [rules](https://alvo.burgyn.online/guides/access-rules/). Use scopes to limit what a credential may ever do, such as a key for a reporting job, and rules to decide what a user may do to which rows. Scopes govern the Data API only. The [Management API](https://alvo.burgyn.online/reference/management-api/) admits a caller by the descriptor's [`access`](https://alvo.burgyn.online/reference/descriptor/access/) block and the key's roles alone, so a key scoped to one entity still reaches management when its roles match an access level. ## People sign in to the dashboard The dashboard at `/admin` does not take API keys. People sign in with an email address and a password that Alvo holds; this build has no other sign-in provider. - **The first administrator** comes from configuration: `Alvo__Admin__BootstrapEmail`, and `Alvo__Admin__BootstrapPasswordFile`, the path of a mounted file that holds the password. The quick start's compose file sets both, and turns `ALVO_ADMIN_PASSWORD` into that file. Passing the password itself in `Alvo__Admin__BootstrapPassword` is refused at startup, because an environment variable shows up in a process listing and in `docker inspect`. The account is created once; changing the file later does not change its password. - **Everyone else** is created by an administrator on the dashboard's *Access* screen, which issues a set-password link. A link works once, for 24 hours by default (an embedded host can change this through `DataProtectionTokenProviderOptions.TokenLifespan`), and the person then signs in as usual. - A password is 15 to 128 characters long and must not contain the email address (or the part before `@`, once that is three characters or more) it signs in with. There are no composition rules. A dashboard session works only in the dashboard: it never turns a signed-in person into a Data API caller. An API key whose `User` is the bootstrap administrator's id is admitted to the Management API as an administrator, whatever the `access` block says. [The admin dashboard](https://alvo.burgyn.online/guides/admin-dashboard/) covers the screens. ## Your own users, in an embedded host When Alvo is embedded in your ASP.NET Core app, your users already sign in to your app. Resolve them in your own endpoints and pass an `AlvoContext` with their id, roles and tenant to `IAlvoData`; Alvo's rules then apply to them exactly as they do to a key. [Use your own authentication](https://alvo.burgyn.online/guides/own-authentication/) shows how. Never read a credential from a cookie. `Alvo:Auth:HeaderName` and `Alvo:Auth:TenantHeaderName` may name any header except `Cookie`: a browser attaches cookies to cross-site requests by itself, which would make every Alvo route a cross-site request forgery target, so the host refuses that configuration at startup. :::caution[Not in this build] The descriptor's [`auth.providers`](https://alvo.burgyn.online/reference/descriptor/auth/#auth.providers) is parsed and warned about, not honoured: Google, Microsoft, GitHub, Apple and OIDC sign-in are tracked in [#36](https://github.com/Burgyn/MMLib.Alvo/issues/36) ([Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/#declared-but-not-run-in-this-build)). There is also no endpoint that issues, rotates or revokes an API key: keys come from configuration only. ::: ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 401 | [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated) | A key was sent and cannot be used: unknown, wrong secret, expired, a role the descriptor does not declare, no role, or an `X-Alvo-Tenant` the key was not issued for. | Check the key against the host's configuration and the descriptor's `auth.roles`. | every host | | 403 | [`out-of-scope`](https://alvo.burgyn.online/reference/problem-types/#out-of-scope) | The key's scopes do not cover this entity and operation. | Grant the key the scope it needs, such as `tickets:write`. | every host | | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | No key was sent, and the rule for this operation reads `@user.id`. | Send a key. | every host | If the container does not come back after you change a key, the end of `docker compose logs alvo` names the key and the setting: an empty or short `Secret`, a duplicate `KeyId`, a `KeyId` with a `.`, or a scope that does not parse. ## Reference - Configuration: [`Alvo:Auth`](https://alvo.burgyn.online/reference/configuration/#alvoauth). - Descriptor keys: [`auth.roles`](https://alvo.burgyn.online/reference/descriptor/auth/#auth.roles), [`auth.providers`](https://alvo.burgyn.online/reference/descriptor/auth/#auth.providers), [`access`](https://alvo.burgyn.online/reference/descriptor/access/). - Problem types: [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated), [`out-of-scope`](https://alvo.burgyn.online/reference/problem-types/#out-of-scope), [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden). - Where scopes and rules are each decided: [`docs/architecture/data-api.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#the-decision-procedure-the-four-ways-a-caller-actually-gets-403). ## Next **[Access rules](https://alvo.burgyn.online/guides/access-rules/)**: decide, per operation, which callers may reach which rows. --- # Multi-tenancy Source: https://alvo.burgyn.online/guides/multi-tenancy/ :::tip[What you will achieve] Two customers, Acme and Globex, sharing one help desk: each sees only its own tickets, both read the same categories, and no request can move a row from one to the other. About fifteen minutes, starting from the stack in [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). ::: ## Before you start - The stack from [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/), run from its `alvo-help-desk` directory with `COMPOSE_FILE` and that page's secrets exported in this shell. Step 1 adds `ALVO_ACME_KEY_SECRET` and `ALVO_GLOBEX_KEY_SECRET`; every compose command needs them too while this page's override is in place. - This page adds two keys that each belong to a tenant: `acme` and `globex` (roles `agent`, `authenticated`). It also uses `admin`, which belongs to no tenant. - [Access rules](https://alvo.burgyn.online/guides/access-rules/), for how rules filter rows. The responses below were captured from a real host when the site was built, so your ids will differ. ## 1. Turn tenancy on A tenant-scoped entity gets a `tenant_id` column, and every read and write of it is limited to the caller's tenant. The tenant comes from the API key, never from the request or the descriptor. In an embedded host that calls `IAlvoData` from its own endpoints, it comes from the `AlvoContext` the host passes. 1. Give each new key a `Tenant`. This override replaces the one from Run your own descriptor and keeps its keys: ```yaml # Saved as docker-compose.override.yml, it replaces the one from "Run your own descriptor" and keeps everything # that one sets. Keys 3 and 4 each belong to one tenant: every row they write lands in that tenant, and they never # see another tenant's rows. name: alvo-help-desk services: alvo: environment: Alvo__DescriptorPath: /alvo/descriptor.json Alvo__Auth__DevKeys__1__KeyId: agent Alvo__Auth__DevKeys__1__Secret: ${ALVO_AGENT_KEY_SECRET:?set ALVO_AGENT_KEY_SECRET before starting the stack} Alvo__Auth__DevKeys__1__User: 3f2b8c1e-7a4d-4e9b-9c21-5d6e7f8a9b01 Alvo__Auth__DevKeys__1__Roles__0: agent Alvo__Auth__DevKeys__1__Roles__1: authenticated Alvo__Auth__DevKeys__1__Scopes__0: "*:read" Alvo__Auth__DevKeys__1__Scopes__1: "*:write" Alvo__Auth__DevKeys__2__KeyId: admin Alvo__Auth__DevKeys__2__Secret: ${ALVO_ADMIN_KEY_SECRET:?set ALVO_ADMIN_KEY_SECRET before starting the stack} Alvo__Auth__DevKeys__2__User: 8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02 Alvo__Auth__DevKeys__2__Roles__0: admin Alvo__Auth__DevKeys__2__Roles__1: authenticated Alvo__Auth__DevKeys__2__Scopes__0: "*:read" Alvo__Auth__DevKeys__2__Scopes__1: "*:write" Alvo__Auth__DevKeys__3__KeyId: acme Alvo__Auth__DevKeys__3__Secret: ${ALVO_ACME_KEY_SECRET:?set ALVO_ACME_KEY_SECRET before starting the stack} Alvo__Auth__DevKeys__3__User: a1c3e5f7-2b4d-4c6e-8f0a-1b3d5f7a9c01 Alvo__Auth__DevKeys__3__Tenant: 0b8f3c2a-5d4e-4f6a-8b7c-1d2e3f4a5b6c Alvo__Auth__DevKeys__3__Roles__0: agent Alvo__Auth__DevKeys__3__Roles__1: authenticated Alvo__Auth__DevKeys__3__Scopes__0: "*:read" Alvo__Auth__DevKeys__3__Scopes__1: "*:write" Alvo__Auth__DevKeys__4__KeyId: globex Alvo__Auth__DevKeys__4__Secret: ${ALVO_GLOBEX_KEY_SECRET:?set ALVO_GLOBEX_KEY_SECRET before starting the stack} Alvo__Auth__DevKeys__4__User: b2d4f6a8-3c5e-4d7f-9a1b-2c4e6f8a0b02 Alvo__Auth__DevKeys__4__Tenant: 7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918 Alvo__Auth__DevKeys__4__Roles__0: agent Alvo__Auth__DevKeys__4__Roles__1: authenticated Alvo__Auth__DevKeys__4__Scopes__0: "*:read" Alvo__Auth__DevKeys__4__Scopes__1: "*:write" volumes: - ./help-desk.alvo.json:/alvo/descriptor.json:ro ``` 2. Turn tenancy on for the whole project, and keep `categories` shared: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "roles": ["admin", "agent"] }, "tenancy": { "enabled": true }, "entities": { "categories": { "tenancy": "global", "fields": { "name": { "type": "string", "required": true, "unique": true, "maxLength": 60 } }, "rules": { "list": "'authenticated' in @user.roles", "get": "'authenticated' in @user.roles", "create": "'admin' in @user.roles", "update": "'admin' in @user.roles", "delete": "'admin' in @user.roles" } }, "tickets": { "fields": { "title": { "type": "string", "required": true, "maxLength": 120 }, "status": { "type": "enum", "values": ["open", "closed"], "default": "open" }, "category_id": { "type": "ref", "entity": "categories" } }, "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" } } } } ``` With `"tenancy": { "enabled": true }` every entity is scoped unless it says `"tenancy": "global"`. Without the project switch, mark single entities with `"tenancy": "scoped"`. 3. Download the override, generate the two secrets, and start the stack over this descriptor. The `down` **deletes the stack's database**: ```sh curl -fsSL -o docker-compose.override.yml https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/shell/multi-tenancy.compose.override.yml export ALVO_ACME_KEY_SECRET="$(openssl rand -hex 16)" export ALVO_GLOBEX_KEY_SECRET="$(openssl rand -hex 16)" docker compose down --volumes curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/multi-tenancy/01-tenancy.alvo.json docker compose up --wait --wait-timeout 90 ``` Every compose command reads the override, so keep its secrets exported in this shell. ## 2. Write and read in a tenant 1. The administrator adds a category. `categories` is global, so a key without a tenant may write it: ```sh curl -sS -X PUT http://localhost:8080/api/categories/c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Hardware"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/categories/c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a { "id": "c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a", "name": "Hardware" } ``` 2. Acme files a ticket. A `POST` to a scoped entity must carry the caller's own `tenant_id`: Alvo checks the value against the key's tenant rather than filling it in. ```sh curl -sS -X POST http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: acme.$ALVO_ACME_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"title":"VPN drops every hour","tenant_id":"0b8f3c2a-5d4e-4f6a-8b7c-1d2e3f4a5b6c","category_id":"c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 Location: /api/tickets/ce0b93df-a80d-4bcd-8bdb-203d3e757c81 { "id": "ce0b93df-a80d-4bcd-8bdb-203d3e757c81", "category_id": "c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a", "status": "open", "tenant_id": "0b8f3c2a-5d4e-4f6a-8b7c-1d2e3f4a5b6c", "title": "VPN drops every hour" } ``` 3. Leave `tenant_id` out, or name another tenant, and the write is refused. The refusal names no field: ```sh curl -sS -X POST http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: acme.$ALVO_ACME_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"title":"Printer on fire"}' ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json { "type": "https://alvo.dev/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "The write was rejected by policy." } ``` A `PUT` that creates a row is the other way round: it must not send `tenant_id`, and the new row lands in the caller's tenant. 4. Globex lists tickets and sees none of Acme's, and asking for Acme's ticket by id is a 404, the same answer as for an id that does not exist: ```sh curl -sS -X GET http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: globex.$ALVO_GLOBEX_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [], "next": null, "count": null } ``` ```sh curl -sS -X GET http://localhost:8080/api/tickets/ce0b93df-a80d-4bcd-8bdb-203d3e757c81 \ -H "X-Alvo-Api-Key: globex.$ALVO_GLOBEX_KEY_SECRET" ``` ```http HTTP/1.1 404 Not Found Content-Type: application/problem+json { "type": "https://alvo.dev/errors/not-found", "title": "Not Found", "status": 404, "detail": "The requested record was not found." } ``` 5. Both tenants read the shared categories: ```sh curl -sS -X GET http://localhost:8080/api/categories \ -H "X-Alvo-Api-Key: globex.$ALVO_GLOBEX_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "id": "c4a7e1d2-9b3f-4e6a-8c5d-2f1e0d9c8b7a", "name": "Hardware" } ], "next": null, "count": null } ``` ## 3. Rows never change tenant 1. `tenant_id` is fixed when the row is created. An update that names it is refused, whatever the value: ```sh curl -sS -X PATCH http://localhost:8080/api/tickets/ce0b93df-a80d-4bcd-8bdb-203d3e757c81 \ -H "X-Alvo-Api-Key: acme.$ALVO_ACME_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"tenant_id":"7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918"}' ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json { "type": "https://alvo.dev/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "Field 'tenant_id' is fixed at creation and a row can never move to another tenant." } ``` 2. A caller without a tenant cannot reach a scoped entity at all, not even an administrator: ```sh curl -sS -X GET http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json { "type": "https://alvo.dev/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "The caller has no tenant, and this entity is tenant-scoped." } ``` 3. The `X-Alvo-Tenant` header can only confirm the key's own tenant. Naming any other tenant makes the key unusable for that request: ```sh curl -sS -X GET http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: acme.$ALVO_ACME_KEY_SECRET" \ -H "X-Alvo-Tenant: 7e6d5c4b-3a29-4180-9f7e-6d5c4b3a2918" ``` ```http HTTP/1.1 401 Unauthorized Content-Type: application/problem+json { "type": "https://alvo.dev/errors/unauthenticated", "title": "Unauthorized", "status": 401, "detail": "The presented API key could not be used. Check the key, whether it has been revoked or has expired, and whether it was issued for the tenant you requested." } ``` The header is optional: without it the key's tenant applies. Its name is configurable as [`Alvo:Auth:TenantHeaderName`](https://alvo.burgyn.online/reference/configuration/#alvoauth). ## How it works A scoped entity gets a tenant condition, `tenant_id == @tenant.id`, compiled like a rule you wrote and added to every operation's own rule, so a list, a read, an update and a delete all carry it in the same `WHERE` clause. A rule may read `@tenant.id` too. A caller whose key has no tenant is refused before any rule runs, so a missing tenant can never match rows by accident. [`docs/architecture/cel.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/cel.md#the-required-context-gate-no-expression-runs-against-a-context-value-the-caller-lacks) has the details. ## What isolation guarantees - **A tenant cannot see, change or delete another tenant's rows.** Every attempt is the answer for a row that does not exist: an empty page or a 404. - **A tenant cannot learn that another tenant's row exists**, with one exception. A reference to a row in another tenant is refused like a reference to nothing (`unresolved-reference`), and a `unique` field is unique per tenant, so two tenants may hold the same value and a duplicate tells you only about your own rows. The exception: a `PUT` create-or-replace on a UUID you already hold answers `409` if a row of any tenant has that id, because the primary key is the id alone. - **A row cannot move.** `tenant_id` is set once, on create, and checked against the key. - **A key acts in one tenant.** The tenant header can confirm it, never widen it. What isolation does not cover: - An after-hook's event carries no tenant ([#153](https://github.com/Burgyn/MMLib.Alvo/issues/153)), and the event queue holds every tenant's complete rows with no retention ([#154](https://github.com/Burgyn/MMLib.Alvo/issues/154)). See [After-hooks, events and webhooks](https://alvo.burgyn.online/guides/after-hooks-and-webhooks/). - A custom CEL function that reads stored data does so outside Alvo's tenant filter: it must take the tenant as a parameter and filter by it. See [Custom CEL functions](https://alvo.burgyn.online/guides/custom-cel-functions/). :::caution[Not in this build] A key that may act across tenants, for example for a support team, is not available: a key without a tenant is refused on every scoped entity, and naming a tenant in the header does not help. A deliberate, audited cross-tenant grant is tracked in [#42](https://github.com/Burgyn/MMLib.Alvo/issues/42). ::: ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | A `POST` left out `tenant_id` or named another tenant; an update or a `PUT` sent `tenant_id`; the key has no tenant and the entity is scoped. | Echo the key's tenant on a `POST`, never send `tenant_id` otherwise, and use a key issued for a tenant. | every host | | 404 | [`not-found`](https://alvo.burgyn.online/reference/problem-types/#not-found) | The row belongs to another tenant, or does not exist. | Use a key of the tenant that owns the row. | every host | | 401 | [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated) | `X-Alvo-Tenant` names a tenant other than the key's own, or is not a valid id. | Drop the header, or send the key's own tenant. | every host | | 422 | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | A `ref` points at a row in another tenant (code `unresolved-reference`). | Reference a row of your own tenant, or a global entity. | every host | ## Reference - Descriptor keys: [`tenancy`](https://alvo.burgyn.online/reference/descriptor/tenancy/), [`tenancy.enabled`](https://alvo.burgyn.online/reference/descriptor/tenancy/#tenancy.enabled), an entity's [`tenancy`](https://alvo.burgyn.online/reference/descriptor/entities/#entities.tenancy). - Configuration: [`Alvo:Auth:DevKeys:{n}:Tenant` and `Alvo:Auth:TenantHeaderName`](https://alvo.burgyn.online/reference/configuration/#alvoauth). - Problem types: [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), [`not-found`](https://alvo.burgyn.online/reference/problem-types/#not-found), [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated), [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation). - Design notes: [`tenant_id` on create and replace](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#tenant_id-is-refused-on-both-branches-and-the-create-branch-stamps-it) and [unique per tenant](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#a-unique-field-on-a-tenant-scoped-entity-was-a-cross-tenant-existence-oracle-137-fixed). ## Next **[Validate and transform writes](https://alvo.burgyn.online/guides/before-hooks/)**: refuse a write, or fill in a value, as a row is stored. --- # Audit row changes Source: https://alvo.burgyn.online/guides/audit-row-changes/ :::tip[What you will achieve] Tickets that record who created them, who last changed them and when, and that refuse an update made against an out-of-date copy. About five minutes, starting from the stack in [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). ::: ## Before you start - The stack from [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/), 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. ## 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`. 1. Start the stack over this page's descriptor. The first command **deletes the stack's database**: ```sh 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: ```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: ```sh 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"}' ``` ```http 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. ## 2. Record each change 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: ```sh 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"}' ``` ```http 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: ```sh 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"}' ``` ```http 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](https://alvo.burgyn.online/guides/write-data/) covers `If-Match` in full. 3. A caller cannot write the audit columns, so a row cannot claim someone else created it: ```sh 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"}' ``` ```http 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." } ``` ## 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](https://alvo.burgyn.online/guides/before-hooks/) 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](https://alvo.burgyn.online/guides/read-data/)), and use `created_by` in a rule: `created_by == @user.id` is the ownership pattern in [Access rules](https://alvo.burgyn.online/guides/access-rules/#2-let-callers-reach-only-their-own-rows). ## 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](https://alvo.burgyn.online/guides/apply-and-evolve/). - **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](https://alvo.burgyn.online/guides/after-hooks-and-webhooks/). :::caution[Not in this build] There is no per-row history of values, and `softDelete`, which would add `deleted_at` and keep deleted rows, is refused at apply because a delete would still remove the row ([Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/#refused-at-apply)). ::: ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | The body names an audit column. | Remove it; Alvo writes those columns. | every host | | 412 | [`precondition-failed`](https://alvo.burgyn.online/reference/problem-types/#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 - Descriptor keys: [`audit`](https://alvo.burgyn.online/reference/descriptor/entities/#entities.audit), [`softDelete`](https://alvo.burgyn.online/reference/descriptor/entities/#entities.softDelete). - Problem types: [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), [`precondition-failed`](https://alvo.burgyn.online/reference/problem-types/#precondition-failed). - Design note: [the framework-managed columns](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-path.md#the-framework-managed-columns-have-one-authority-and-the-framework-writes-them). ## Next **[Read data: filter, sort, page](https://alvo.burgyn.online/guides/read-data/)**: query rows with filters, ordering and keyset pages. --- # After-hooks, events and webhooks Source: https://alvo.burgyn.online/guides/after-hooks-and-webhooks/ :::tip[What you will achieve] A high-priority ticket is posted to your webhook, and closing a ticket sends a mail to the support lead. You will know what the receiver gets, when, how often it is retried, and which networks it may reach. About fifteen minutes, starting from the stack in [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). ::: ## Before you start - The stack from [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/), 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`). - An HTTPS endpoint you control, if you want to see a delivery arrive. The descriptor below points at `ops.example.com`, which does not exist, so its deliveries fail and are retried. ## 1. Declare the endpoint and the template Every write appends an event, and an after-hook says which events to act on and what to do. This build runs two actions: `webhook`, which posts the event to an endpoint, and `email`, which renders a template. Both targets are declared once, at the top of the descriptor. 1. Start the stack over this page's descriptor. The first command **deletes the stack's database**: ```sh docker compose down --volumes curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/after-hooks-and-webhooks/01-webhook.alvo.json docker compose up --wait --wait-timeout 90 ``` 2. The descriptor declares the `ops-board` endpoint and the `ticket-closed` template, and two after-hooks use them: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "roles": ["admin", "agent"] }, "webhooks": { "endpoints": { "ops-board": { "url": "https://ops.example.com/hooks/alvo", "secretRef": "ops-board-signing-key" } } }, "templates": { "ticket-closed": { "subject": "Ticket closed: {{new.title}}", "body": "The ticket '{{new.title}}' was closed.\n\nResolution: {{new.resolution}}" } }, "entities": { "tickets": { "audit": true, "fields": { "title": { "type": "string", "required": true, "maxLength": 120 }, "priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" }, "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": { "afterCreate": [ { "condition": "new.priority == 'high'", "action": { "type": "webhook", "endpoint": "ops-board" } } ], "afterUpdate": [ { "condition": "changed(status) && new.status == 'closed'", "action": { "type": "email", "template": "ticket-closed", "to": "support-lead@example.com" } } ] } } } } ``` - An endpoint's `url` must be `https`; plain `http` is refused at apply, except for a loopback address such as `http://127.0.0.1:5081/hook`. The schema requires `secretRef`, but this build does not read it: deliveries are not signed (see *Not in this build* below). - A template's `subject` and `body` take `{{…}}` placeholders over `new.`, `old.`, `event.id`, `event.type`, `event.time`, `event.subject` and `@user.id`. A placeholder that names a field the entity does not have is refused at apply, never rendered empty. - A condition reads `new.`, `old.`, `changed()` and `@user.id`. `@user.id` is the user who made the change. `@user.roles` and `@tenant.id` are refused in an after-hook condition, because the event does not record them. ## 2. Trigger it 1. Create a high-priority ticket, then close it: ```sh 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":"Server room is flooding","priority":"high"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155119029550" Location: /api/tickets/bf69649d-4067-41c6-a8da-1bb022eb191e { "id": "bf69649d-4067-41c6-a8da-1bb022eb191e", "title": "Server room is flooding", "priority": "high", "status": "open" } ``` ```sh curl -sS -X PATCH http://localhost:8080/api/tickets/bf69649d-4067-41c6-a8da-1bb022eb191e \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"status":"closed","resolution":"Pumped out; the drain is cleared."}' ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 ETag: "639273155119452460" { "id": "bf69649d-4067-41c6-a8da-1bb022eb191e", "status": "closed", "resolution": "Pumped out; the drain is cleared." } ``` Both writes answered at once. The after-hooks run later, after the commit, so nothing they do can change or delay the answer. 2. Read what happened in the log: ```sh docker compose logs alvo | grep -E "ran after-hook|failed to deliver|development email provider" ``` The mail is there: this build's only mail provider writes each message to the log (recipient and subject; the body only at `Debug`) instead of sending it. The webhook to `ops.example.com` shows as *failed to deliver … on attempt 1*, then 2, and so on, until it reaches the attempt ceiling. ## 3. What the receiver gets A webhook's body is the event, a [CloudEvents 1.0](https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md) envelope in JSON. This one is pinned by the test suite; an update carries both images and the fields that moved: ```json {"specversion":"1.0","id":"019fc77e-be7b-72e8-b7fd-ffd6f6306e3e","source":"/alvo","type":"entity.vehicles.updated","time":"2026-08-03T09:30:00.0000000\u002B00:00","subject":"vehicles/3f2504e0-4f89-41d3-9a0c-0305e82c3301","datacontenttype":"application/json","partitionkey":"vehicles:3f2504e0-4f89-41d3-9a0c-0305e82c3301","payloadversion":1,"chaindepth":0,"authtype":"apikey","authid":"key-42","correlationid":"4bf92f3577b34da6a3ce929d0e0e4736","data":{"record":{"id":"3f2504e0-4f89-41d3-9a0c-0305e82c3301","make":"vw","status":"approved","price":19.99},"old_record":{"id":"3f2504e0-4f89-41d3-9a0c-0305e82c3301","make":"vw","status":"draft","price":19.99},"changed":["status"]}} ``` In this build `authid` is the id of the user the key acts as, not the key's id: the sample's `key-42` is a placeholder, and the design note's wording is tracked in [#344](https://github.com/Burgyn/MMLib.Alvo/issues/344). | Member | What it holds | |---|---| | `id` | The event's id. Delivery is at least once, so **deduplicate on `id`**: it is the one value that stays the same across retries. | | `type` | `entity..created`, `.updated` or `.deleted`. | | `subject`, `partitionkey` | The row: `/` and `:`. | | `time` | The instant of the write, the same one its audit columns record, in UTC. | | `authtype`, `authid` | `apikey`, `system` or `anon`, and the id of the user who made the change (absent for an anonymous caller). | | `correlationid` | The trace id of the request that made the change, or the event's own id when there was none. | | `data.record`, `data.old_record` | The row after and before the write. `old_record` is absent on a create, `record` on a delete. | | `data.changed` | The fields whose value moved. | | `specversion`, `source`, `datacontenttype`, `payloadversion`, `chaindepth`, `causationid` | CloudEvents and Alvo bookkeeping: `1.0`, `/alvo`, `application/json`, the payload schema version, the hook-chain depth, and the causing event's id when there is one. | **The record is complete.** It includes fields that are `hidden` from callers, because the endpoint is declared by the same author who declared the `hidden` rule. Treat the endpoint as a place that sees every field. A webhook's optional [`payload`](https://alvo.burgyn.online/reference/descriptor/entities-hooks/#entities.hooks.afterCreate.action.payload) replaces the envelope with a `{{…}}` template you write, which must be JSON around its placeholders. ## 4. Delivery and retries The event is written in the same transaction as the row, so a write that rolls back leaves no event, and a committed write always has one. One background dispatcher then claims events in order and runs each matching after-hook. - **Every failure is retried**, whatever it was: a 500, a 404, a timeout, a name that does not resolve. A redirect is a failure too; the client follows none. An endpoint that answers 2xx is done. - **Each attempt waits at most 10 seconds** for a 2xx; a slower endpoint counts as a failure. The body is sent as `application/json`. - **Retries back off linearly:** the wait grows by one poll interval per attempt, so at the defaults ten attempts span at least 45 seconds. After [`Alvo:Events:MaxAttempts`](https://alvo.burgyn.online/reference/configuration/#alvoevents) (10) the event is left alone: it stays in the outbox table, undelivered, and the log has an error line naming it. Nothing deletes it, and nothing redelivers it. - **Retries are not in order.** An event whose delivery failed is tried again later, after events that came behind it, including events for the same row: above, the close's mail went out while the create's webhook was still failing. - **Run one dispatcher.** Two hosts draining one database break ordering silently ([#150](https://github.com/Burgyn/MMLib.Alvo/issues/150)). With several replicas, set `Alvo:Events:Enabled` to `false` on all but one: writes still append events, and the one dispatcher delivers them. Alvo's own log lines never write an endpoint's URL, because a URL can hold the only secret an unsigned endpoint has: a failed attempt names the event id and type, and the attached error names the endpoint by its descriptor name. Neither does the HTTP client's: Alvo registers the webhook client without the default transport loggers, so no line carries the URL at any log level. An embedded host that adds its own logger to that client gets transport lines back, and owns their redaction. ## 5. Allow an internal network Webhook delivery refuses every address that is not public: private ranges, loopback, link-local (including the cloud metadata endpoint), carrier-grade NAT, multicast and reserved ranges. It is judged on the address the name resolves to, when the connection opens, so a public-looking name that points inside your network is refused too. Loopback is allowed only when the URL names it literally, such as `127.0.0.1` or `localhost`. To deliver to a service on your own network, list its network in CIDR notation under [`Alvo:Events:WebhookAllowedNetworks`](https://alvo.burgyn.online/reference/configuration/#alvoevents), one entry per index: `Alvo__Events__WebhookAllowedNetworks__0=10.20.0.0/16`. A listed network overrides every other refusal, so list the narrowest one that works. An entry that is not CIDR notation is refused at startup. A refused address is an ordinary failed delivery, retried like any other. ## 6. Publish your own event An embedded host can put its own events on the same durable queue with `IAlvoEvents`: ```csharp Task PublishAsync( string type, string subject, IReadOnlyDictionary? data, AlvoContext context, CancellationToken cancellationToken = default); ``` The `type` is two or more lower-case segments separated by dots, such as `orders.approved`, and never starts with `entity.`, `auth.` or `storage.`: those are Alvo's own, and a name in one is refused. Values in `data` must be scalars. Know what a custom event is not, before you build on it: - **Nothing subscribes to it yet.** No after-hook can name it, so the dispatcher records it and runs nothing. - **It is not part of your transaction.** It is appended on its own, so it can commit when your write does not. - **A retried publish appends a second event**, with a new `id`. - **It carries no tenant.** :::caution[Not in this build] - **Signing.** No delivery is signed: `secretRef` is not read and no Standard Webhooks `webhook-signature` header is sent, so a receiver cannot verify that a request came from Alvo. Put the receiver where only Alvo can reach it, or give it a URL nobody can guess. - **Projection.** The body is the whole envelope or the whole rendered `payload`; there is no per-endpoint selection of fields ([#152](https://github.com/Burgyn/MMLib.Alvo/issues/152)). - **Mail.** The only mail provider writes to the log. An embedded host can register its own `IEmailSender`. - **Other actions.** `function`, `entity.update` and `http.call` are refused by name at apply; a JSONata expression in `payload` is refused too ([#149](https://github.com/Burgyn/MMLib.Alvo/issues/149)), and so are an email's `data` and a template's `bodyFile`. - **`automation`**, rules that run on events or on a schedule, is parsed and warned about, and nothing runs. - **A dead-letter queue** with redelivery, and **retention** for the outbox table, which keeps every event's complete rows ([#154](https://github.com/Burgyn/MMLib.Alvo/issues/154)). [Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/) lists each of these in the framework's own words. ::: If an email's `to` is a placeholder such as `{{new.contact_email}}`, anyone who can write the row chooses who receives the mail and its data. It is harmless with the log-only provider; decide it deliberately before you register a real one. ## What can go wrong An after-hook never changes the answer to the write that triggered it, so it returns no problem type. What can go wrong shows up when the descriptor is applied, or in the log: | Where | When | Fix | |---|---|---| | Apply | The endpoint's URL is relative, or `http` to a host that is not loopback. | Use an absolute `https` URL. | | Apply | A placeholder names a field the entity does not have, or a hook names an endpoint or a template that is not declared. | Correct the name; the refusal points at the hook. | | Apply | The action is `function`, `entity.update` or `http.call`, or `payload` holds JSONata. | Use `webhook` or `email`, and a `{{…}}` template. | | Apply | An after-hook condition reads `@user.roles` or `@tenant.id`. | Read the row's own fields, or `@user.id`. | | Log | *failed to deliver … on attempt N* | Check the endpoint; a non-public address needs `Alvo:Events:WebhookAllowedNetworks`. | An apply refusal stops the container from coming back, and 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](https://github.com/Burgyn/MMLib.Alvo/issues/340)). ## Reference - Descriptor keys: [`webhooks`](https://alvo.burgyn.online/reference/descriptor/webhooks/), [`templates`](https://alvo.burgyn.online/reference/descriptor/templates/), [`afterCreate`](https://alvo.burgyn.online/reference/descriptor/entities-hooks/#entities.hooks.afterCreate), [`afterUpdate`](https://alvo.burgyn.online/reference/descriptor/entities-hooks/#entities.hooks.afterUpdate), [`afterDelete`](https://alvo.burgyn.online/reference/descriptor/entities-hooks/#entities.hooks.afterDelete). - Configuration: [`Alvo:Events`](https://alvo.burgyn.online/reference/configuration/#alvoevents). - C#: [`IAlvoEvents`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-abstractions/). - Design notes: [the envelope](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/events.md#the-envelope), [after-hooks](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/events.md#after-hooks) and [the attempt ceiling](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/events.md#the-attempt-ceiling-is-the-dlq-stand-in-and-abandoned-is-observable). ## Next **[Audit row changes](https://alvo.burgyn.online/guides/audit-row-changes/)**: record who created and last changed each row. --- # The admin dashboard Source: https://alvo.burgyn.online/guides/admin-dashboard/ :::tip[What you will achieve] A tour of the dashboard by doing: sign in, read what the backend is, change a rule with the dashboard checking it as you type, preview and apply the change, browse records as yourself, and roll the change back. About fifteen minutes, starting from the [Quick start](https://alvo.burgyn.online/start-here/quick-start/)'s compose file. ::: ## Before you start - A running dashboard. The screenshots show a bicycle workshop's backend, `examples/bike-workshop`, which the image carries; the quick start's stack serves it with no clone. Its records are empty there. The seeded demo, with realistic data, runs from a clone and needs the .NET SDK the repository pins in `global.json`, plus `curl` and `jq`. - An account with a management level. The bootstrap administrator has `admin`, whatever the descriptor says. The dashboard is a client of the [Management API](https://alvo.burgyn.online/reference/management-api/) and the Data API, never a second way in: every screen does what an agent could do over HTTP, under the same checks. ## 1. Sign in Start the quick start's stack over the workshop, in the directory that holds `docker-compose.quickstart.yml` and with `ALVO_DEMO_KEY_SECRET` and `ALVO_ADMIN_PASSWORD` exported. The `down` deletes the database of the descriptor it served before: ```sh docker compose -f docker-compose.quickstart.yml down --volumes ALVO_DESCRIPTOR=/alvo/examples/bike-workshop/bike-workshop.alvo.json \ docker compose -f docker-compose.quickstart.yml up --wait --wait-timeout 90 ``` Open `http://localhost:8080/admin` and sign in as `admin@alvo.local` with the password you exported: ```sh echo "$ALVO_ADMIN_PASSWORD" ``` For the seeded demo instead, run this in a clone of the repository. It builds the host, starts it on `http://127.0.0.1:5080` over a fresh SQLite file, seeds the workshop and prints the dashboard address, the administrator's address and a password for this run: ```sh scripts/demo-admin --no-ai ``` The dashboard takes an address and a password, never an API key. The first administrator comes from configuration; everyone else is created on **Access**, which hands you a one-time set-password link to pass on, because this build sends no email. [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/#people-sign-in-to-the-dashboard) covers both. What you may do once signed in is your management level, decided by the descriptor's `access` block from your roles: a `viewer` reads, a `developer` also applies and rolls back, an `admin` also changes `access`, the people and the AI connection. ## 2. Read the overview **Overview** answers "what is this backend": the applied revision, how the host starts, how many entities, revisions and honoured blocks there are, and the latest configuration change. The panel *Declared, not honoured by this build* lists only the blocks your descriptor declares that this build parses and does not run, each with the framework's own sentence for what does not happen. It is the same answer [Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/) is generated from. **Automations** and **Functions** in the navigation carry a *Not yet* badge for the same reason. ## 3. Change the schema **Schema** lists the entities. Open one to see its tabs: *Fields*, *Relationships*, *Rules*, *On write* (the hooks), *Indexes* and *API*, the routes the Data API generates for it. Every edit, here or on any other screen, goes into one **working copy** of the descriptor. Nothing changes in the database until you apply it. A bar shows how many changes are unapplied, and its action is always **Preview changes**: 1. **Preview** shows the descriptor diff and the migration plan Alvo would run. A rules-only change has an empty plan, which means "no schema step", not "nothing to apply". 2. Type the reason under **Why**. Configuration history shows it on the revision forever. 3. **Apply these changes** applies against the revision you started from. If somebody applied first, yours is refused rather than written over theirs. A plan that destroys data asks you to type the project's name first. The *Map* view draws every entity and relation, read-only; a relation is added as a `ref` field in the entity's own editor. *Import / export* downloads the stored descriptor byte for byte, or loads a pasted one into the working copy, where it goes through the same preview. ## 4. Edit rules and hooks, checked as you type **Rules** shows, per entity, the rule for each operation beside the route it guards, and a simulator. Change a rule and pause: after about 300 milliseconds the dashboard asks the Management API's `cel/check` what an apply would say about exactly that expression, in exactly that place, and shows the answer under the box. A typo in a role name is caught before you save, with the declared roles listed. The check needs the `developer` level; a `viewer` sees the rules and the simulator only: **Save rule** (or Ctrl+Enter, ⌘+Enter) puts the rule in the working copy; Escape puts back the saved one. The *On write* tab edits the before-hooks and after-hooks with the same check on every condition and `mutate` value. The simulator answers what the policy engine decides for a caller and an operation, from the same engine the Data API uses: it hands back the predicate that will filter the rows, never a verdict on one row. Every screen works at phone width: ## 5. Browse the data as yourself **Data** lists the entities; open one to page, search, create, edit and delete records. The browser reads through the ordinary Data API under **your** identity and your rules, so you see exactly the rows you would see over HTTP; there is no administrator bypass. A signed-in person acts in at most one tenant, set on **Access**. Without one, tenant-scoped entities are closed to you, and the screen says so. ## 6. Roll back a change **Configuration history** lists every revision: who applied it, when, why, and what changed. Every apply appends one, and none is ever rewritten. Select an older revision and choose **Plan the rollback** to see the reverse migration, then **Roll back**. A rollback always asks you to type the project's name, because it replaces the whole configuration, and it warns when the reverse migration would discard data added since. It is appended as a new revision; nothing is erased. On a stack that serves a descriptor file, put the restored descriptor back in the file afterwards: [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/) explains why. ## The other screens - **Access**: people, their roles and their tenant, which take effect at once; and the role catalogue and the three management levels, which are part of the descriptor and wait for an apply. A role the descriptor does not declare is never given to a signed-in person, so assigning one does nothing. Locked accounts can be unlocked here. - **Integrations**: the descriptor's webhook endpoints and templates, beside what this build does not run for them. - **Settings**: what is running and how it starts, where the API documentation is, and the [schema assistant](https://alvo.burgyn.online/guides/schema-assistant/)'s connection. - **Search** (Ctrl+K, ⌘K) jumps to any screen or entity. :::caution[Not in this build] There is no danger zone: deleting a project has no route. API keys are neither issued nor revoked here, because no endpoint does that yet ([#36](https://github.com/Burgyn/MMLib.Alvo/issues/36)). There is no audit trail of data changes, only of configuration, and no automations or functions screen beyond the *Not yet* placeholder ([Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/)). The *author* a Management API caller sends is not verified ([#344](https://github.com/Burgyn/MMLib.Alvo/issues/344)); a dashboard apply records the signed-in person. ::: :::caution[Known issue in this build] A rule, a hook or a change to an existing entity's fields takes effect on the next request, but a new entity reaches the Data API only after the host restarts: it has no route until then ([#103](https://github.com/Burgyn/MMLib.Alvo/issues/103)). ::: ## What can go wrong The dashboard runs the Management API's operations in process, so it refuses for the same reasons; the table names the problem type the same refusal carries over HTTP. | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | Your roles reach no level the action needs, or a `developer` applies a change to `access`. | Ask an `admin` to grant the level, or to make the access change. | every host | | 412 | [`precondition-failed`](https://alvo.burgyn.online/reference/problem-types/#precondition-failed) | Somebody applied a revision after the one your working copy started from. | Start again from the new revision and redo your edits. | every host | | 422 | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | The working copy would not apply, for example an expression that does not compile. | Follow the refusal's pointer and fix; the live check usually shows it before you save. | every host | ## Reference - [Management API](https://alvo.burgyn.online/reference/management-api/): the operations every screen calls, and their levels. - Descriptor keys: [`access`](https://alvo.burgyn.online/reference/descriptor/access/), [`auth`](https://alvo.burgyn.online/reference/descriptor/auth/). - Configuration: [`Alvo:Admin`](https://alvo.burgyn.online/reference/configuration/#alvoadmin), [`Alvo:Admin:Dashboard`](https://alvo.burgyn.online/reference/configuration/#alvoadmindashboard). - Problem types: [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), [`precondition-failed`](https://alvo.burgyn.online/reference/problem-types/#precondition-failed), [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation). ## Next **[The schema assistant](https://alvo.burgyn.online/guides/schema-assistant/)**: describe a change in words and review the proposal the assistant checks for you. --- # The schema assistant Source: https://alvo.burgyn.online/guides/schema-assistant/ :::tip[What you will achieve] A schema assistant in your dashboard that turns "add a notes field to bikes" into a descriptor change Alvo has already checked, which you then review and apply. About ten minutes, starting from a running dashboard and access to a model (OpenAI, Azure OpenAI, or a local OpenAI-compatible server such as Ollama). ::: ## Before you start - A running dashboard: [The admin dashboard](https://alvo.burgyn.online/guides/admin-dashboard/). Its demo asks for a model connection when it starts; `--no-ai` is what skips it. - The `admin` management level to save a connection from the dashboard. Asking the assistant needs only what the question touches: it acts with your level, never more. ## What it can and cannot do The assistant reads the project and runs dry runs. It **cannot apply anything**: there is no apply tool in its tool set, so no instruction, however it is phrased, gives it one. Every change it makes is a proposal you review and apply. The table is its complete tool set: five reads, two dry runs and two skill readers. | It may call | What for | |---|---| | `get_descriptor`, `get_schema` | the current descriptor and the revision a change must be written against, and the resolved schema | | `get_capabilities` | what this build honours, so it says "not in this build" instead of writing a key nothing runs | | `get_cel_functions` | the CEL functions this host knows, built in and registered by the host, with the profiles each works in | | `get_revisions` | what changed recently, and why | | `check_change`, `propose_change` | a dry run of a change: "would this apply?", and the only way a change becomes a proposal | | `load_skill`, `read_skill_resource` | the nine descriptor skills, and the schema slices they cite | What follows from that: - **It reaches what you reach.** It calls the Management API in process as you, so a `viewer` gets a `viewer`'s answers, and there is no service account behind it. - **It never sees your data.** The Data API is not among its tools, so no record reaches the model. - **A proposal is already checked.** It passed the same dry run an apply runs. When Alvo refuses a draft, the assistant reads the refusal, fixes the draft and tries again within the same turn, a bounded number of times. - **It cannot drop data.** Its dry runs never allow a destructive plan, so a change that would drop a column comes back as a refusal it has to show you. - **It says "proposed", never "done"**, and answers in the language you wrote in. It quotes what this build cannot do from `get_capabilities` rather than from memory. Its instructions and skills are compiled into the package. A deployment cannot edit them, because an editable prompt would be a way to change what the assistant believes with nothing guarding it. ## 1. Connect a model The connection is infrastructure, not part of the descriptor, so it lives in configuration or in the host's secret store. Four keys, under [`Alvo:Ai`](https://alvo.burgyn.online/reference/configuration/#alvoai): | Key | Value | |---|---| | `Alvo__Ai__Kind` | `openai-compatible` or `azure-openai` | | `Alvo__Ai__Endpoint` | the base address: `https://api.openai.com/v1`, `http://localhost:11434/v1` for Ollama, or `https://.openai.azure.com` | | `Alvo__Ai__Model` | the model, or the Azure OpenAI deployment name | | `Alvo__Ai__ApiKeySecretRef` | the **name** of the secret that holds the API key, such as `openai`; none for a local endpoint that needs no key | The key itself is a secret: `Alvo__Secrets__Values__openai`, from whatever configuration source your deployment keeps secrets in. [Running in production](https://alvo.burgyn.online/guides/production/#3-keep-every-secret-in-a-file) covers the secret store. Or save the connection from the dashboard: **Settings**, then **Edit the connection** under *AI assistant*. That needs the `admin` level, and an encryption key file (`Alvo:Secrets:EncryptionKeyFile`) so the host has somewhere encrypted to keep the key. A connection in configuration wins over a saved one, and then the dashboard shows it but cannot change it. *Settings* reports whether a connection is configured, its kind and model, and where it came from, never its endpoint or key. It also warns when the key is missing and every request will be refused. With no connection at all, the assistant is simply absent: no launcher appears, and the rest of the dashboard works as before. ## 2. Ask for a change Open the assistant with **Ask Alvo** in the top bar and describe the change: *"Bikes need an optional notes field."* While it works, it streams its answer and the tools it calls. When a draft passes the dry run, the pane shows **A proposed change**, written against the current revision, with the words *Nothing has been applied*. When every attempt in the turn was refused, it shows the refusals instead. *Turn details* lists the calls it made and what each one returned. ## 3. Review and apply it yourself **Review it in Preview** puts the proposal into your working copy and opens **Preview changes**: the same diff, the same migration plan and the same **Apply** button as a change you made by hand. If the working copy already holds edits of your own, the dashboard asks before replacing them. The *Why* box is pre-filled with what you asked for; change it as you like. The revision records **you** as its author, because a person decided to apply it, and you can roll it back from *Configuration history* like any other. [The admin dashboard](https://alvo.burgyn.online/guides/admin-dashboard/#3-change-the-schema) covers Preview and Apply. ## The skills it shares with coding agents The assistant learns the descriptor from nine skills: entities and fields, field types and formats, rules and CEL, hooks, computed fields and rollups, indexes, traits and tenancy, project access, and capabilities and limits. They are plain Markdown files in the repository, `plugins/alvo/skills/alvo-descriptor-*`, and the package embeds exactly those files, so the assistant and your coding agent learn the same rules from one source. [For coding agents](https://alvo.burgyn.online/start-here/coding-agents/#3-load-the-shared-skills) installs them as a Claude Code plugin, or into any agent's skills folder. ## In an embedded host The standalone image includes the assistant. An embedded host adds the `MMLib.Alvo.Ai` package and registers it with `AddAlvoAi()` ([C# reference](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-ai/)); the core never references it, so a host that wants only the Data API never acquires an agent runtime. ## What can go wrong The assistant and the dashboard call the Management API's operations in process, so they refuse for the same reasons; the table names the problem type the same refusal carries over HTTP. | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | You save the AI connection without the `admin` level (`PUT …/ai/connection`), or ask about something your level may not read. | Ask an `admin`, or grant your role the level in `access`. | every host | | 422 | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | Every draft in the turn was refused; the pane lists the refusals. | Rephrase or narrow the request, or make the change by hand following the refusal's fix. | every host | **No launcher appears.** No connection is configured: set `Alvo:Ai`, or save one from *Settings*. **Every turn fails, and *Settings* says the key is missing.** The secret `Alvo:Ai:ApiKeySecretRef` names does not exist, or the endpoint needs a key and none was saved. Save the secret under that name, or edit the connection and type the key. ## Reference - Configuration: [`Alvo:Ai`](https://alvo.burgyn.online/reference/configuration/#alvoai), [`Alvo:Secrets`](https://alvo.burgyn.online/reference/configuration/#alvosecrets). - [Management API](https://alvo.burgyn.online/reference/management-api/): `GET …/info` and `PUT …/ai/connection`. - [`MMLib.Alvo.Ai`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-ai/). - Problem types: [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation). - Design note: [the package boundary](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/package-boundary.md). ## Next **[For coding agents](https://alvo.burgyn.online/start-here/coding-agents/)**: give your own agent the same skills, the schema and a safe way to apply. --- # Running in production Source: https://alvo.burgyn.online/guides/production/ :::tip[What you will achieve] A standalone host configured the way a production deployment should be: PostgreSQL, every secret in a mounted file, the `Verify` startup mode, the right probe for each job, and a clear list of what to decide before you expose it. About twenty minutes, starting from the published image with Docker running. ::: ## Before you start - Docker with Compose v2, `curl` and `openssl`: [Quick start](https://alvo.burgyn.online/start-here/quick-start/) and [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/) show the compose file this page builds on. - The descriptor you will serve, already working on that stack. This page uses the quick start's default, `examples/vehicle-registry`, and its `demo` key (`ALVO_DEMO_KEY_SECRET`). :::caution[Not in this build] Alvo is pre-v0.1, so **there is no release tag yet**: the only published tag is `edge`, which follows `main`. There is also no endpoint that issues or revokes API keys; keys come from `Alvo:Auth:DevKeys` in configuration until [#36](https://github.com/Burgyn/MMLib.Alvo/issues/36) lands. The environment-variable names below may still change before the first release ([#233](https://github.com/Burgyn/MMLib.Alvo/issues/233)). ::: ## 1. Get the image The image is published as `ghcr.io/burgyn/alvo`, for `linux/amd64` and `linux/arm64`: ```sh docker pull ghcr.io/burgyn/alvo:edge ``` `edge` is built from every push to `main`, and `sha-` with the commit's first seven characters names one build for good. A release tag `v1.2.3` will publish `1.2.3`, `1.2` and `latest`; pin a version, never `edge`, once one exists. The compose file reads the image from `ALVO_IMAGE`, so `ALVO_IMAGE=ghcr.io/burgyn/alvo:` pins one. It runs as a non-root user, listens on port **8080**, reads the descriptor from `Alvo__DescriptorPath` (`/alvo/descriptor.json` when you mount your own), and ships no API key and no administrator password. It also carries the examples that apply, read-only under `/alvo/examples/`. It runs without ICU, so culture-sensitive comparison and formatting use the invariant culture. To build the same image from source instead, from the repository root, because the build needs the root's package and build settings: ```sh git clone https://github.com/Burgyn/MMLib.Alvo && cd MMLib.Alvo docker build -f src/MMLib.Alvo.Host/Dockerfile -t alvo:local . ``` Then set `ALVO_IMAGE=alvo:local` for the compose commands below. ## 2. Choose the database The host registers exactly one database driver, chosen by name with [`Alvo:Database:Provider`](https://alvo.burgyn.online/reference/configuration/#alvo). An unknown name stops the host rather than falling back to a default. | | PostgreSQL | SQLite | |---|---|---| | Provider name | `postgresql` | `sqlite`, the default | | Connection string | `ConnectionStrings__Alvo`; without one the host refuses to start | optional: `ConnectionStrings__Alvo`, else `Alvo__Database__SqliteConnectionString`; the default is `Data Source=/alvo/data/alvo.db`, a file inside the container | | Published latency numbers | measured on it | not measured | Use PostgreSQL. The SQLite default exists so a first `docker run` needs nothing, and its file is lost with the container unless you mount `/alvo/data`. That is also why a PostgreSQL host with no connection string is refused: the alternative would be writing your rows to a file that disappears. The quick start's `docker-compose.quickstart.yml` already runs the host on PostgreSQL 16; its database password is a demo value, so give your own deployment a real one. ## 3. Keep every secret in a file Three kinds of secret reach the host, and two of them are accepted **only** as the path of a mounted file. Passing the value itself in an environment variable is refused at startup, because an environment variable shows up in a process listing, in a crash dump and in `docker inspect`. - **The dashboard's first administrator**: `Alvo__Admin__BootstrapEmail`, and the password file in `Alvo__Admin__BootstrapPasswordFile`. `Alvo__Admin__BootstrapPassword` is refused. - **The key the database-backed secret store encrypts with**, 32 bytes in base64: the file in `Alvo__Secrets__EncryptionKeyFile`. `Alvo__Secrets__EncryptionKey` is refused. - **Each named secret**, such as the AI connection's key: `Alvo__Secrets__Values__`, from any configuration source the host reads. A secret in configuration wins over one saved in the database, and saving one under a name configuration already carries is refused. Without an encryption key file there is no writable store at all rather than a fallback key, so nothing can be saved from the dashboard. The bootstrap administrator is created once: a changed password file does not change an existing account's password. The image runs as a non-root user, so the files must be readable by it. The quick start's compose file already hands the bootstrap administrator's password over as a mounted file, from `ALVO_ADMIN_PASSWORD`. This override puts the rest of the production settings on top of it, the encryption key among them: ```yaml # Saved as docker-compose.production.yml next to docker-compose.quickstart.yml, which already runs the host on # PostgreSQL and hands the bootstrap administrator's password over as a mounted file. Every secret this adds is a # mounted file too, never a value in the environment. name: alvo-production services: alvo: environment: # Refuse to start on a drifted schema; apply descriptor changes from one place. Alvo__Schema__Startup: Verify # The key the database-backed secret store encrypts with: 32 bytes, base64. Alvo__Secrets__EncryptionKeyFile: /run/secrets/alvo_encryption_key # Publishing the API's shape is your call: false removes /scalar and /openapi/v1.json. Alvo__Docs__Enabled: "false" secrets: - alvo_encryption_key volumes: # The data-protection key ring lives in the app user's home: keep it, and sessions and # set-password links survive a recreated container. - alvo-home:/home/app secrets: alvo_encryption_key: file: ./.alvo-encryption-key volumes: alvo-home: ``` ## 4. Set the startup mode [`Alvo:Schema:Startup`](https://alvo.burgyn.online/reference/configuration/#alvoschema) decides what a boot does when the mounted descriptor no longer matches the schema in the database. The default is `Apply`, which suits editing a descriptor and restarting. Production should set `Verify`, as the override does, and apply descriptor changes from one place: a single migration job, the [Management API](https://alvo.burgyn.online/guides/apply-and-evolve/) or the dashboard. | Mode | When the descriptor has drifted | What it costs | |---|---|---| | `Apply` (default) | applies the plan | every replica of a rolling deploy attempts the change, and the application needs rights to change its own database's schema | | `Verify` | refuses to start, printing the steps it would take and the fix | a descriptor change takes effect only once it is applied from that one place | | `Skip` | serves the schema as it is | the schema is entirely someone else's job | An empty database is initialized in every mode but `Skip`, so the first start works under `Verify`. No mode ever discards data on boot: a plan that drops or narrows something is refused unless `Alvo__Schema__AllowDestructive=true`. The sharper cost of `Apply` is the rollback. A forward deploy under `Apply` changes the schema without anyone deciding to. Redeploying the previous descriptor then plans a drop against that schema, and the drop is refused, so **the rollback cannot start**. A host holding a descriptor older than the database's history starts but reports not ready, with a log line naming both revisions. Under `Verify` you applied the forward change on purpose and can plan the way back. ## 5. Start it and probe it Make a directory, download the compose file and the override, generate the encryption key file and the two secrets, then start the stack with both files. `COMPOSE_FILE` names them, so every compose command in this shell uses both: ```sh mkdir -p alvo-production && cd alvo-production curl -fsSLO https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/docker-compose.quickstart.yml curl -fsSL -o docker-compose.production.yml https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/shell/production.compose.override.yml openssl rand -base64 32 > .alvo-encryption-key export COMPOSE_FILE=docker-compose.quickstart.yml:docker-compose.production.yml export ALVO_DEMO_KEY_SECRET="$(openssl rand -hex 16)" ALVO_ADMIN_PASSWORD="$(openssl rand -hex 12)" docker compose up --wait --wait-timeout 90 ``` `--wait` returns once the stack's health check passes, and that check asks readiness. Then ask both probes yourself: ```sh curl -sS -o /dev/null -w 'live: %{http_code}\n' localhost:8080/health/live curl -sS -o /dev/null -w 'ready: %{http_code}\n' localhost:8080/health/ready ``` Both answer `200`. They are configured oppositely on purpose: | Probe | Answers | Use it for | |---|---|---| | `/health/live` | `200` for any process that is up; it checks nothing at all | the liveness probe: a failure restarts the container | | `/health/ready` | `200` once the descriptor applied and while the database answers within two seconds; `503` otherwise | the readiness probe and the compose health check: a failure only takes the instance out of traffic | A database outage therefore drains the instance and never restarts it in a loop. The readiness body is the bare phase word in plain text; the reason for a failure goes to the log, never to this unauthenticated route. Readiness opens a database connection per request, so keep the probe port away from untrusted callers ([#183](https://github.com/Burgyn/MMLib.Alvo/issues/183)). When you are done, stop the stack and delete its volumes and the generated key file, in the same shell: ```sh docker compose down --volumes rm .alvo-encryption-key unset COMPOSE_FILE ``` ## 6. Put it behind a reverse proxy Two settings, both off by default: - [`Alvo:PathBase`](https://alvo.burgyn.online/reference/configuration/#alvo) serves the whole application under a prefix, for a proxy that does not strip it. The dashboard always lives at `/admin` under that base; it has no mount point of its own. - `Alvo:ForwardedHeaders:Enabled` honours `X-Forwarded-For`, `-Proto`, `-Host` and `-Prefix`. A created row's `Location` header is built from them. With it on, the host trusts those headers **from anyone**, because a container cannot know its proxy's address. **The host must then be reachable only through the proxy**: a client that reaches it directly chooses the URL your next client is sent to, and its own rate-limit partition on the dashboard's sign-in form. ASP.NET Core's own `ASPNETCORE_FORWARDEDHEADERS_ENABLED` does not grant this trust; only the Alvo setting does. The API browser at `/scalar` behind a path base has not been verified ([#134](https://github.com/Burgyn/MMLib.Alvo/issues/134)). ## 7. Decide what to expose - **The API's shape.** `/scalar` and `/openapi/v1.json` are anonymous. They publish which entities exist and their non-hidden fields, never data. [`Alvo:Docs:Enabled`](https://alvo.burgyn.online/reference/configuration/#alvo) set to `false` removes both routes, as the override does. - **The Management API** at `/management` is closed by default: until the descriptor's `access` block grants a level, only the bootstrap administrator gets in. Nothing logs or rate-limits it, so rate-limit it at the proxy (or, in an embedded host, on the routes you map). - **The dashboard** is on by default. `Alvo__Admin__Dashboard__Enabled=false` removes it, together with its sign-in and set-password endpoints. Both forms are rate-limited per subject and per client; the keys and defaults are in [Configuration keys](https://alvo.burgyn.online/reference/configuration/#keys-read-outside-an-options-type). Behind a proxy without forwarded headers, every client shares the proxy's one budget. ## Run more than one instance - **One instance drains the outbox.** Per-row event order holds only while a single process delivers events (and no two events for one row are written by different processes in the same millisecond), and no lock enforces it: run one instance with [`Alvo:Events:Enabled`](https://alvo.burgyn.online/reference/configuration/#alvoevents) on and the rest off. Switching it off stops delivery, never the recording of events. - **Keep the data-protection keys.** Dashboard sessions and set-password links are protected with keys the host keeps in its user's home directory, `/home/app`. A recreated container loses them, and every session and outstanding link with them; a second instance cannot read the first one's. That fails closed: people sign in again or ask for a new link. The override mounts a volume at `/home/app`, so the keys survive a recreated container. They are stored there unencrypted, so protect the volume. - **Replicas racing the same schema change converge** rather than crash, but `Verify` with one writer is the shape that never races at all. ## Measured performance The published numbers were measured over PostgreSQL 16 with 200 000 rows, on one laptop with the load generator on the same machine: about 5 ms p95 to read one row, about 16 ms for a filtered, sorted list on an indexed column, and about 12 ms to create a row. The full table, the method and how to reproduce it are in [`docs/performance.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/performance.md). They describe that machine, not a throughput promise. ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 500 | [`internal`](https://alvo.burgyn.online/reference/problem-types/#internal) | Something Alvo relies on broke while it served a generated route. The answer carries a constant detail, never the exception. | Read the host's log, which has the exception and its stack trace. | standalone; embedded only with `AddAlvoProblemDetails()` | | 503 | — | `/health/ready` before the descriptor applied, while the database does not answer, or when the host holds a descriptor older than the database's history. | Read the log: it names the failure, or the two revisions. | every host | **The host refuses to start.** A configuration it cannot accept stops it with one sentence per problem on stderr and exit code **78**, so a script can tell "an operator must change something" from a crash. That covers a missing descriptor, an unknown database provider, a PostgreSQL host with no connection string, a secret passed as a value, a dev key secret under 32 characters, drift under `Verify` and a plan that would discard data. A descriptor that fails validation currently exits with code 139 instead ([#340](https://github.com/Burgyn/MMLib.Alvo/issues/340)). **The first log lines on PostgreSQL say `libgssapi_krb5.so.2` cannot be loaded.** The database driver looks for Kerberos support, which the image does not carry; the host starts and serves normally. ## Reference - Configuration: [`Alvo`](https://alvo.burgyn.online/reference/configuration/#alvo) (descriptor, database, path base, forwarded headers, docs), [`Alvo:Schema`](https://alvo.burgyn.online/reference/configuration/#alvoschema), [`Alvo:Secrets`](https://alvo.burgyn.online/reference/configuration/#alvosecrets), [`Alvo:Events`](https://alvo.burgyn.online/reference/configuration/#alvoevents), [keys read outside an options type](https://alvo.burgyn.online/reference/configuration/#keys-read-outside-an-options-type), and [Limits and budgets](https://alvo.burgyn.online/reference/limits/). - Problem types: [`internal`](https://alvo.burgyn.online/reference/problem-types/#internal). - Design note: [the standalone host](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/host.md). ## Next **[The admin dashboard](https://alvo.burgyn.online/guides/admin-dashboard/)**: sign in and manage the backend you just deployed. --- # Apply and evolve your descriptor Source: https://alvo.burgyn.online/guides/apply-and-evolve/ :::tip[What you will achieve] A descriptor change applied to your running backend without a restart: previewed first, then applied against the revision you read and recorded with a reason (in this build a new field or entity is served only after a restart; see the known issue below). Then the rest of a change's lifecycle, each shown with a real response: a lost race, a rename that keeps its data, a deliberate drop and a rollback. About fifteen minutes, starting from the stack in [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). ::: ## Before you start - The stack from [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/), run from its `alvo-help-desk` directory with `COMPOSE_FILE` and that page's secrets exported in this shell, plus `curl` and `jq`. - The `admin` key (roles `admin`, `authenticated`). This page's descriptor grants that role the `developer` level of the Management API. ## Three ways to apply a change Every way ends in the same place: Alvo compares the new descriptor with the schema it applied before, plans the migration, refuses a plan that would discard data unless you allow it, and appends a **revision** to the project's history. They differ in who applies and what is recorded. | Way | Who applies | What is recorded | What it refuses | |---|---|---|---| | **Edit the file and restart** the host | whoever deploys | a new revision, with no author and no reason | an invalid descriptor; a plan that discards data, unless `Alvo:Schema:AllowDestructive` is `true`; a descriptor older than the one the database holds | | **The [Management API](https://alvo.burgyn.online/reference/management-api/)**: `PUT …/descriptor` | a caller whose roles reach the `developer` level in `access`, or `admin` to change `access` itself | a new revision, with the `author` (not verified, [#344](https://github.com/Burgyn/MMLib.Alvo/issues/344)) and `reason` the request sends | an invalid descriptor (422); no `If-Match` (428); a stale one (412); a plan that discards data, unless the body allows it (409) | | **The [admin dashboard](https://alvo.burgyn.online/guides/admin-dashboard/)**: *Schema*, then *Preview changes* | a signed-in person with the `developer` level | a new revision, authored by that person, with the reason typed under *Why* | what the API refuses; a plan that discards data asks you to type the project's name; if someone applied first, yours is refused rather than written over theirs | **On restart**, what the host does when the file no longer matches the database is the startup mode, [`Alvo:Schema:Startup`](https://alvo.burgyn.online/reference/configuration/#alvoschema): - `Apply` (the default) applies the plan, still refusing any step that discards data. That is the loop [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/) uses: ```sh docker compose up -d --wait --force-recreate alvo ``` - `Verify` refuses to start with a drifted schema and prints the steps it would take. Production should set it (`Alvo__Schema__Startup=Verify`) and apply the descriptor from one migration job: under `Apply`, every replica of a rolling deploy attempts the DDL and the application needs DDL rights on its own database. - `Skip` serves the schema as it is, does not even create it in an empty database, and leaves it entirely to someone else. The rest of this page uses the Management API, which applies without a restart. The dashboard runs the same calls in-process, so it plans, refuses and records exactly the same way. :::caution[Known issue in this build] A change applied through the Management API or the dashboard is recorded and migrated at once, and almost all of it is served at once: rules, hooks and an existing entity's fields and facets (a new field, a shorter `maxLength`, a newly `required` field) take effect on the next request. A new entity gets no Data API route until the host restarts ([#103](https://github.com/Burgyn/MMLib.Alvo/issues/103)). The recipe below downloads the new descriptor over the mounted file, so a restart serves the same descriptor. ::: ## 1. Give the API a way in Every Management API route is closed to every caller except the bootstrap administrator until the descriptor's `access` block grants a level. This descriptor grants `developer` to callers with the `admin` role: ```json "access": { "developer": "'admin' in @user.roles" } ``` Start the stack over it. The first command **deletes the stack's database**: ```sh docker compose down --volumes curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/apply-and-evolve/01-base.alvo.json docker compose up --wait --wait-timeout 90 ``` A `developer` may apply and roll back, but not change the `access` block itself; that takes `admin`. [For coding agents](https://alvo.burgyn.online/start-here/coding-agents/#access-levels) lists the three levels. ## 2. Preview a change The change adds a `category` to tickets: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "providers": ["local"], "roles": ["admin", "agent"] }, "access": { "developer": "'admin' in @user.roles" }, "entities": { "tickets": { "fields": { "title": { "type": "string", "required": true, "maxLength": 120 }, "body": { "type": "text" }, "priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" }, "status": { "type": "enum", "values": ["open", "closed"], "default": "open" }, "category": { "type": "enum", "values": ["question", "incident", "request"] } }, "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" } } } } ``` 1. Read the current descriptor. Its `revision` is the version you are editing: ```http GET /management/projects/help-desk/descriptor HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "project": "help-desk", "revision": 1 } ``` 2. Send the whole new descriptor with `?dryRun=true`. The body is `{"descriptorJson": ""}`, and `If-Match` carries the revision you read. The answer is the plan, and nothing is applied: ```http PUT /management/projects/help-desk/descriptor?dryRun=true HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET If-Match: "1" Content-Type: application/json ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "applied": false, "revision": 1, "plan": { "isEmpty": false, "hasDestructiveChanges": false, "steps": [ "AddField tickets.category" ] }, "replayed": false } ``` A dry run runs the same checks as a real apply, so a preview that passes is an apply that will pass, unless someone else applies first. Its refusals are the apply's too: in this build a dry run of a plan that discards data is refused like the apply itself unless the body carries `"allowDestructive": true` ([#343](https://github.com/Burgyn/MMLib.Alvo/issues/343)), so to preview such a plan you send the allowance (step 6 shows it). A dry run that carries an `Idempotency-Key` is refused: it appends no revision, so there is nothing to replay. ## 3. Apply it 1. A write must say which revision it replaces. Without `If-Match`, the API refuses rather than risk overwriting a change it cannot see: ```http PUT /management/projects/help-desk/descriptor HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET Content-Type: application/json ``` ```http HTTP/1.1 428 Precondition Required Content-Type: application/problem+json { "type": "https://alvo.dev/errors/precondition-required", "title": "Precondition Required", "status": 428, "detail": "This write requires 'If-Match' carrying the descriptor's current revision, e.g. If-Match: \"3\". Read that revision from GET the same path. Applying without one is a lost update nothing would detect." } ``` 2. Send `If-Match`, an `Idempotency-Key`, and a `reason`, which the history keeps. From a shell, `jq` builds the body from the file: ```sh curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/apply-and-evolve/02-add-field.alvo.json REVISION="$(curl -sS localhost:8080/management/projects/help-desk/descriptor \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" | jq -r .revision)" jq -n --rawfile d help-desk.alvo.json '{descriptorJson: $d, reason: "Sort tickets by category."}' \ | curl -sS -X PUT localhost:8080/management/projects/help-desk/descriptor \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "If-Match: \"$REVISION\"" \ -H "Idempotency-Key: help-desk-add-category" \ -H "Content-Type: application/json" \ -d @- ``` The revision advances and the plan says what ran: ```http PUT /management/projects/help-desk/descriptor HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET If-Match: "1" Idempotency-Key: help-desk-add-category Content-Type: application/json ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "applied": true, "revision": 2, "plan": { "isEmpty": false, "hasDestructiveChanges": false, "steps": [ "AddField tickets.category" ] }, "replayed": false } ``` The recipe downloads the new descriptor over the mounted file first, so the file and the database agree and the next restart has nothing to apply. 3. The history lists every revision. Revision 1 is the one the host recorded when it started, with no author or reason: ```http GET /management/projects/help-desk/revisions HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 [ { "revision": 1, "createdAt": "2026-10-11T11:38:33.2644979+00:00", "author": null, "reason": null, "rolledBackFrom": null }, { "revision": 2, "createdAt": "2026-10-11T11:38:33.5345134+00:00", "author": null, "reason": "Sort tickets by category.", "rolledBackFrom": null } ] ``` 4. If the response is lost, send the same request again with the same key. The answer is the revision the first request appended, with `replayed: true`, and the plan comes back empty, because this request ran nothing. Read what a revision applied from `GET …/revisions/{n}`. The key can only repeat that request: the same key with a different body is refused, as here: ```http PUT /management/projects/help-desk/descriptor HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET If-Match: "1" Idempotency-Key: help-desk-add-category Content-Type: application/json ``` ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json { "type": "https://alvo.dev/errors/idempotency-conflict", "title": "Conflict", "status": 409, "detail": "This 'Idempotency-Key' was already spent on a different request. Send a fresh key with this body, or resend the original body to replay the write it recorded." } ``` From here on, each response was captured on a fresh host started from the descriptor the step names, so on the stack you have been using the revision numbers are higher. Always send the revision your own `GET` returned. ## 4. Lose a race safely Two editors read revision 1. The first applies and the descriptor moves to revision 2. The second still sends `If-Match: "1"`: ```http PUT /management/projects/help-desk/descriptor HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET If-Match: "1" Content-Type: application/json ``` ```http HTTP/1.1 412 Precondition Failed Content-Type: application/problem+json { "type": "https://alvo.dev/errors/precondition-failed", "title": "Precondition Failed", "status": 412, "detail": "Descriptor for project 'help-desk' changed concurrently: expected revision 1, but current is 2. Reload the latest revision and retry." } ``` Read the descriptor again, make your change on top of revision 2, and send `If-Match: "2"`. A 428 means you sent no revision; a 412 means you sent one that no longer holds. ## 5. Rename without losing data To rename a field, rename its key and name the old one in `renamedFrom`. Here `body` becomes `details`: ```json "details": { "type": "text", "renamedFrom": "body" } ``` The plan renames the column, and every value stays: ```http PUT /management/projects/help-desk/descriptor?dryRun=true HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET If-Match: "1" Content-Type: application/json ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "applied": false, "revision": 1, "plan": { "isEmpty": false, "hasDestructiveChanges": false, "steps": [ "RenameField tickets.details" ] }, "replayed": false } ``` Without `renamedFrom`, the same edit is a drop of `body` and an add of `details`, and the plan is refused because it discards `body`'s data: ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json { "type": "https://alvo.dev/errors/destructive-change", "title": "Conflict", "status": 409, "detail": "The change to project 'help-desk' is destructive and was refused. Re-issue with AllowDestructive=true after reviewing the dry-run. Send 'allowDestructive': true to proceed, or change the descriptor to keep what the plan would drop." } ``` An entity is renamed the same way: rename it under `entities` and set its own `renamedFrom`. ## 6. Make a destructive change deliberately Removing a field drops its column and all its data. This descriptor removes `priority`: ```json "fields": { "title": { "type": "string", "required": true, "maxLength": 120 }, "body": { "type": "text" }, "status": { "type": "enum", "values": [ "open", "closed" ], "default": "open" }, "category": { "type": "enum", "values": [ "question", "incident", "request" ] } } ``` 1. Applied like any other change, it is refused, and nothing is touched: ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json { "type": "https://alvo.dev/errors/destructive-change", "title": "Conflict", "status": 409, "detail": "The change to project 'help-desk' is destructive and was refused. Re-issue with AllowDestructive=true after reviewing the dry-run. Send 'allowDestructive': true to proceed, or change the descriptor to keep what the plan would drop." } ``` 2. To see what you would lose, preview it. In this build a dry run of a destructive plan is refused like the apply unless it carries `"allowDestructive": true` too ([#343](https://github.com/Burgyn/MMLib.Alvo/issues/343)); a dry run applies nothing either way. The destructive steps are marked: ```http PUT /management/projects/help-desk/descriptor?dryRun=true HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET If-Match: "1" Content-Type: application/json ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "applied": false, "revision": 1, "plan": { "isEmpty": false, "hasDestructiveChanges": true, "steps": [ "DropField tickets.priority: Drops the column and all its data. <- destructive" ] }, "replayed": false } ``` 3. When you are sure, send the same body without `?dryRun=true`, with a `reason`: ```http PUT /management/projects/help-desk/descriptor HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET If-Match: "1" Content-Type: application/json ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "applied": true, "revision": 2, "plan": { "isEmpty": false, "hasDestructiveChanges": true, "steps": [ "DropField tickets.priority: Drops the column and all its data. <- destructive" ] }, "replayed": false } ``` Permission to lose data is never implied: not by a level, not by an earlier dry run. Each request that discards data carries `allowDestructive` itself, and no rollback brings the data back. ## 7. Roll back A rollback restores an earlier revision's descriptor. It does not rewrite history: it appends a new revision. 1. Preview the rollback to revision 1. `If-Match` carries the current revision. Going back drops the `category` column that revision 2 added, so in this build the preview is refused without `"allowDestructive": true` ([#343](https://github.com/Burgyn/MMLib.Alvo/issues/343)): ```http POST /management/projects/help-desk/revisions/1/rollback?dryRun=true HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET If-Match: "2" Content-Type: application/json { "allowDestructive": true } ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "applied": false, "revision": 2, "plan": { "isEmpty": false, "hasDestructiveChanges": true, "steps": [ "DropField tickets.category: Drops the column and all its data. <- destructive" ] }, "replayed": false } ``` 2. Roll back, with a reason: ```http POST /management/projects/help-desk/revisions/1/rollback HTTP/1.1 X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET If-Match: "2" Content-Type: application/json { "allowDestructive": true, "reason": "Category was premature." } ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "applied": true, "revision": 3, "plan": { "isEmpty": false, "hasDestructiveChanges": true, "steps": [ "DropField tickets.category: Drops the column and all its data. <- destructive" ] }, "replayed": false } ``` 3. The history now holds three revisions, and the third records which one it restored: ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 [ { "revision": 1, "createdAt": "2026-10-11T11:38:34.7490382+00:00", "author": null, "reason": null, "rolledBackFrom": null }, { "revision": 2, "createdAt": "2026-10-11T11:38:35.0672216+00:00", "author": null, "reason": null, "rolledBackFrom": null }, { "revision": 3, "createdAt": "2026-10-11T11:38:35.1286712+00:00", "author": null, "reason": "Category was premature.", "rolledBackFrom": 1 } ] ``` After a rollback through the API, put the restored descriptor back in the file the stack mounts, `help-desk.alvo.json` in the `alvo-help-desk` directory. Here the restored revision is the page's first descriptor: ```sh curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/apply-and-evolve/01-base.alvo.json ``` Otherwise the file still holds the descriptor you rolled back from, which the history now holds at an older revision, and a host that restarts with it stands down instead of serving it. In the dashboard, *Configuration history* lists the same revisions, and *Plan the rollback* shows the same plan before you apply it. ## How it works Every apply, on restart or through the API, plans the migration by comparing two schemas, refuses a destructive plan without an explicit allowance, then applies the plan and appends the revision in one transaction. The history is append-only: no revision is ever rewritten, so it is the audit trail of what the backend was, when, and why. The Management API and the dashboard call the same code, so they cannot disagree. [`docs/architecture/management-api.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/management-api.md) records each decision. ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | The caller's roles reach no level the route needs, or a `developer` changed the `access` block or rolled back to a revision whose block differs. | Grant the role a level in `access`; have an `admin` make access changes. | every host | | 409 | [`destructive-change`](https://alvo.burgyn.online/reference/problem-types/#destructive-change) | The plan would discard data, and the request did not allow it. | Keep what the plan drops, or preview with `"allowDestructive": true` and resend with it. | any host that maps the Management API | | 409 | [`idempotency-conflict`](https://alvo.burgyn.online/reference/problem-types/#idempotency-conflict) | The `Idempotency-Key` was already used by this caller for a different request. | Use a fresh key for a new request. | every host | | 412 | [`precondition-failed`](https://alvo.burgyn.online/reference/problem-types/#precondition-failed) | `If-Match` names a revision that is no longer current. | Read the descriptor again, redo your change on it, and send the new revision. | every host | | 422 | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | The descriptor is refused: an expression that does not compile, a facet on the wrong type, a key this build refuses. Each violation's `pointer` names the place. | Fix what the violation's `fixSuggestion` says. | every host | | 428 | [`precondition-required`](https://alvo.burgyn.online/reference/problem-types/#precondition-required) | The write carried no `If-Match`. | Read the current revision and send it as `If-Match: ""`. | any host that maps the Management API | **The host does not come back after you edit the file.** The reason is at the end of `docker compose logs alvo`; see [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/#what-can-go-wrong). - `Alvo cannot start:` with a plan whose steps are marked destructive, and exit code 78: the edit discards data. Keep what it drops, apply it through the API with `allowDestructive`, or set `Alvo__Schema__AllowDestructive=true` for that one start. - `Descriptor validation failed:`: the descriptor is invalid. In this build the process exits with code 139 instead of 78 ([#340](https://github.com/Burgyn/MMLib.Alvo/issues/340)). - The host starts but never reports ready, and the log names two revisions: the file holds a descriptor the history has at an older revision than the database, for example after a rollback through the API. Put the current descriptor back in the file. `AllowDestructive` alone does not make an older descriptor serve. ## Reference - [Management API](https://alvo.burgyn.online/reference/management-api/): every route and its body (`descriptorJson`, `allowDestructive`, `author`, `reason`). - Configuration: [`Alvo:Schema:Startup` and `Alvo:Schema:AllowDestructive`](https://alvo.burgyn.online/reference/configuration/#alvoschema). - Descriptor keys: [`access`](https://alvo.burgyn.online/reference/descriptor/access/), [a field's `renamedFrom`](https://alvo.burgyn.online/reference/descriptor/entities-fields/#entities.fields.renamedFrom), [an entity's `renamedFrom`](https://alvo.burgyn.online/reference/descriptor/entities/#entities.renamedFrom). - Problem types: [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), [`destructive-change`](https://alvo.burgyn.online/reference/problem-types/#destructive-change), [`idempotency-conflict`](https://alvo.burgyn.online/reference/problem-types/#idempotency-conflict), [`precondition-failed`](https://alvo.burgyn.online/reference/problem-types/#precondition-failed), [`precondition-required`](https://alvo.burgyn.online/reference/problem-types/#precondition-required), [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation). - The startup mode and production, in depth: [`docs/architecture/host.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/host.md#the-startup-mode-and-what-production-should-set). ## Next **[Authentication and API keys](https://alvo.burgyn.online/guides/authentication/)**: decide who your callers are, the first step of securing what you modelled. --- # Call Alvo from your endpoints Source: https://alvo.burgyn.online/guides/call-from-endpoints/ :::tip[What you will achieve] Your own endpoints that query and update Alvo's rows with no HTTP round trip and no authorization code of their own, the generated API mounted under a prefix you choose, and refusals rendered as your app's own responses. About fifteen minutes, starting from an app that embeds Alvo as in [Embed Alvo in ASP.NET Core](https://alvo.burgyn.online/start-here/embed/). ::: ## Before you start - An app that embeds Alvo as in [Embed Alvo in ASP.NET Core](https://alvo.burgyn.online/start-here/embed/). This page reads one `Program.cs` that adds a cookie sign-in and two endpoints of its own. - A way to build an `AlvoContext` for your signed-in user: [Use your own authentication](https://alvo.burgyn.online/guides/own-authentication/) shows the lines that build one. - To try the endpoints, the commands in [Use your own authentication, step 4](https://alvo.burgyn.online/guides/own-authentication/#4-try-it), or a descriptor of your own from [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). ## 1. Call `IAlvoData` from an endpoint `IAlvoData` is the one port every Alvo read and write goes through, the same one the generated API calls. Resolve it in a minimal-API delegate like any service, and pass the caller you built. A read: ```csharp app.MapGet("/app/vehicles", async (HttpContext http, IAlvoData data, IRoleCatalogProvider roles, CancellationToken ct) => { if (roles.DeclaredRoles is not { } catalog) { return Results.Problem(detail: "Alvo has not applied a descriptor yet.", statusCode: StatusCodes.Status503ServiceUnavailable); } if (!Guid.TryParse(http.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) { return Results.Unauthorized(); } var caller = new AlvoContext { User = new UserId(userId), Roles = catalog.Resolve(["authenticated", .. http.User.FindAll(ClaimTypes.Role).Select(c => c.Value).Where(r => catalog.TryGet(r, out _))]), }; try { var page = await data.QueryAsync(new AlvoQuery { Entity = "vehicles", Limit = 50 }, caller, ct); return Results.Ok(page.Items.Select(row => row.Values)); } catch (AlvoAuthorizationException exception) { return Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status403Forbidden); } }).RequireAuthorization(); ``` `QueryAsync` takes an `AlvoQuery`: `Entity`, and optionally `Filter`, `Sort`, `Select`, `Limit`, `After` (a keyset cursor) or `Offset`, and `IncludeTotalCount`. It returns an `AlvoPage` with `Items`, `NextCursor` and `TotalCount`; its `Items` are records whose `Values` hold each record's fields. The rows are the ones the `list` rule admits for this caller; the endpoint filters nothing itself. In process, `Limit` may be left `null` to read the whole visible set, which the HTTP API never allows. A write takes your own request type and maps it to the field dictionary Alvo expects: ```csharp app.MapPatch("/app/vehicles/{id:guid}", async (Guid id, RepaintRequest request, HttpContext http, IAlvoData data, IRoleCatalogProvider roles, CancellationToken ct) => { if (roles.DeclaredRoles is not { } catalog) { return Results.Problem(detail: "Alvo has not applied a descriptor yet.", statusCode: StatusCodes.Status503ServiceUnavailable); } if (!Guid.TryParse(http.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) { return Results.Unauthorized(); } var caller = new AlvoContext { User = new UserId(userId), Roles = catalog.Resolve(["authenticated", .. http.User.FindAll(ClaimTypes.Role).Select(c => c.Value).Where(r => catalog.TryGet(r, out _))]), }; try { var record = await data.UpdateAsync("vehicles", id, new Dictionary { ["color"] = request.Color }, caller, cancellationToken: ct); return Results.Ok(record.Values); } catch (AlvoAuthorizationException exception) { return Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status403Forbidden); } catch (AlvoRecordNotFoundException) { return Results.NotFound(); } catch (ArgumentException exception) { return Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status422UnprocessableEntity); } }).RequireAuthorization(); ``` Map your DTO to a `Dictionary` yourself rather than binding one straight from JSON: a dictionary bound from JSON carries `JsonElement` values, which `IAlvoData` has no field type for and refuses. `UpdateAsync` also takes an optional precondition (the `If-Match` of the HTTP API) and an idempotency token. `CreateAsync`, `GetAsync`, `ReplaceAsync`, `DeleteAsync` and the three batch methods follow the same shape; each is in the [C# API reference](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-abstractions/#ialvodata). The before-hooks, the rules and the after-hook events all run inside these calls, exactly as for an HTTP request. ## 2. Mount the generated API under your prefix The generated Data API defaults to `/api`. An embedded host usually moves it out of its own way with `AlvoApiOptions.RoutePrefix`, set where Alvo is registered: ```csharp builder.Services.AddAlvo(alvo => alvo .UseSqlite("Data Source=alvo.db") .FromDescriptor("vehicles.alvo.json") .AddDataApi(api => api.RoutePrefix = "/api/alvo")); ``` Then map it. `MapAlvoDataApi()` returns a convention builder over Alvo's generated routes and nothing else, so a convention you attach reaches those routes only. Here they are grouped under one tag in the OpenAPI document: ```csharp app.MapAlvoHealth(); app.MapAlvoDataApi().WithTags("alvo-data-api"); ``` - **Attach conventions before the first request.** The route table is built once, at the first request; a convention attached later throws, rather than being silently ignored like a late `RequireRateLimiting` would be. - **Health maps first and takes no conventions.** `MapAlvoHealth()` is not chainable, so an authorization policy can never reach `/health/live`, which a container probe calls without a credential. - **A route group works too.** `app.MapGroup("/backend").MapAlvoDataApi()` mounts the API under the group's prefix, and a created row's `Location` header carries it, as it carries a path base set with `UsePathBase`. `RoutePrefix` is also the configuration key `Alvo:Api:RoutePrefix`; an empty string mounts the API at the root. ## 3. Render what `IAlvoData` refuses `IAlvoData` refuses by throwing, one exception type per kind of refusal. The generated API turns them into problem documents; your endpoint decides what to answer. The write above renders three kinds as its own responses: ```csharp var record = await data.UpdateAsync("vehicles", id, new Dictionary { ["color"] = request.Color }, caller, cancellationToken: ct); return Results.Ok(record.Values); } catch (AlvoAuthorizationException exception) { return Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status403Forbidden); } catch (AlvoRecordNotFoundException) { return Results.NotFound(); } catch (ArgumentException exception) { return Results.Problem(detail: exception.Message, statusCode: StatusCodes.Status422UnprocessableEntity); ``` The read catches `AlvoAuthorizationException` too: a denied `list` throws exactly as a denied `update` does, so an endpoint that catches only around its writes ships a 500 for the first descriptor with a narrower read rule. The complete list, with what the generated API answers for each: | Exception | Means | The generated API answers | |---|---|---| | `AlvoAuthorizationException` | No rule allows the operation, or the row a write would store fails the rule or a before-hook. | 403 `forbidden` | | `AlvoRecordNotFoundException` | The row of an update or delete does not exist, or the rule hides it. `GetAsync` returns `null` instead. | 404 `not-found` | | `ArgumentException`, except `ArgumentNullException` | The query or the values are malformed. | 422 `validation` or `malformed-query` | | `AlvoPreconditionFailedException` | The precondition names a version the row no longer has. | 412 `precondition-failed` | | `AlvoIdempotencyConflictException` | The idempotency key was used for a different request. | 409 `idempotency-conflict` | | `AlvoConstraintViolationException` | A `unique` value or a `restrict` reference is in the way. | 409 `conflict` | | `InvalidOperationException`, `ArgumentNullException` | An invariant inside Alvo, or your call, is broken. | 500 `internal`, with `AddAlvoProblemDetails()` | | `Exception` | A CEL function failed: a built-in refused its input, or a custom function threw. | 500 `function-failed`, with `AddAlvoProblemDetails()` | Catch the ones your endpoint can cause. The write sends no precondition, no idempotency key and no unique field, so it catches three. Let `InvalidOperationException` and `ArgumentNullException` propagate: they mean a bug, and your logging needs the stack trace. A CEL function that fails during your write (a built-in refusing its input, or a [custom function](https://alvo.burgyn.online/guides/custom-cel-functions/) that throws) reaches you as a plain `Exception`, outside these families, and nothing is written. The messages of `AlvoAuthorizationException` and `AlvoRecordNotFoundException` are safe to pass on: they never name the entity, the row or whether it exists. ## How it works The generated API's endpoints are thin: each resolves its caller from the API key, calls `IAlvoData`, and maps the exception families to problem documents. Your endpoint does the same with a caller of its own, which is why both surfaces enforce identical rules. Because `IAlvoData` takes the caller as a parameter instead of reading it from the request, the same call works from a background job or a message handler, where no HTTP request exists. [Architecture](https://alvo.burgyn.online/concepts/architecture/) shows where the port sits. ## What can go wrong The generated routes under your prefix answer with Alvo's problem types; your own endpoints answer with whatever you render. | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | `AlvoAuthorizationException`: no rule allows it, or the row fails the rule or a hook. | Check the rule against the roles in your `AlvoContext`. | every host | | 404 | [`not-found`](https://alvo.burgyn.online/reference/problem-types/#not-found) | `AlvoRecordNotFoundException`: the row is absent or hidden by the rule. | Check the id and the rule. | every host | | 422 | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | `ArgumentException`: a value does not fit its field, or a field is unknown. | Map your DTO to the entity's declared fields and types. | every host | | 500 | [`internal`](https://alvo.burgyn.online/reference/problem-types/#internal) | `InvalidOperationException` or `ArgumentNullException`: a broken invariant. | Read the stack trace in your log. | standalone; embedded only with `AddAlvoProblemDetails()` | Two failures happen outside a request. `MapAlvoDataApi()` on a host whose Data API services are missing stops the app at startup; call `AddAlvo` first. A convention that throws while the routes are built does not stop the host: the route table stays empty, `/health/ready` reports the failure, and the log entry names `MapAlvoDataApi()`. ## Reference - C# API: [`IAlvoData`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-abstractions/#ialvodata), [`MapAlvoDataApi`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo/#mapalvodataapi), [`AlvoApiOptions`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo/#alvoapioptions), [`AddAlvoProblemDetails`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo/#addalvoproblemdetails). - Configuration: [`Alvo:Api`](https://alvo.burgyn.online/reference/configuration/#alvoapi). - Design notes: [what a host may attach to the generated routes](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#what-a-host-may-attach-to-the-generated-routes-182) and [endpoints as a separate seam](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/extensibility.md#strict-rules). ## Next **[Custom CEL functions](https://alvo.burgyn.online/guides/custom-cel-functions/)**: give your descriptor's hooks a function written in C#. --- # Custom CEL functions Source: https://alvo.burgyn.online/guides/custom-cel-functions/ :::tip[What you will achieve] A C# function, `normalizeVin`, that your descriptor's before-hook calls by name to store every vehicle identification number in one canonical form. About fifteen minutes, starting from an app that embeds Alvo as in [Embed Alvo in ASP.NET Core](https://alvo.burgyn.online/start-here/embed/). ::: ## Before you start - The repository's embedded sample, which registers this same function, from [Embed Alvo in ASP.NET Core](https://alvo.burgyn.online/start-here/embed/), 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](https://alvo.burgyn.online/start-here/run-your-own/) knows the built-in functions only. - [Before-hooks](https://alvo.burgyn.online/guides/before-hooks/): a function is called from a hook's `condition` or `mutate`. ## 1. Register the function 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: ```csharp using MMLib.Alvo.Auth; var builder = WebApplication.CreateBuilder(args); builder.Services.Configure(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: | What | Allowed | |---|---| | Name | A 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`. | | Parameters | At most four, each `string`, `long`, `int`, `decimal`, `bool`, `DateTimeOffset` or `Guid`, or a nullable one of those. | | Result | One of the same types. | | Shape | A 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. ## 2. Call it from a hook 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: ```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. ## 3. Try it Start the sample over the descriptor with the hook, in the background and over a fresh database: ```sh 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: ```sh 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. ## 4. Find it in the catalog 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//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](https://alvo.burgyn.online/guides/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](https://alvo.burgyn.online/guides/schema-assistant/) learns the same list through its `get_cel_functions` tool. The built-ins are in the [CEL function catalog](https://alvo.burgyn.online/reference/cel-functions/). ## 5. Keep the function's promises 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](https://alvo.burgyn.online/guides/after-hooks-and-webhooks/). - **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. ## How it works 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](https://alvo.burgyn.online/concepts/cel/) explains the profiles. :::caution[Not in this build] Built-in functions are not translated to SQL yet; host functions never run in rules, computed fields or `access`. A custom function runs with no time budget and no cancellation, so a slow one holds the write's transaction and its row locks ([#309](https://github.com/Burgyn/MMLib.Alvo/issues/309)). The standalone image and `alvo validate` know the built-in functions only. See [Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/) for the descriptor blocks this build does not run. ::: ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 500 | [`function-failed`](https://alvo.burgyn.online/reference/problem-types/#function-failed) | The 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()` | | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | The value a `mutate` computed breaks a facet of its field, such as `maxLength`. | Make the function's result fit the field. | every host | | 422 | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | The 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](https://alvo.burgyn.online/guides/call-from-endpoints/#3-render-what-ialvodata-refuses)). 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`. ## Reference - C# API: [`AddCelFunction`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo/#addcelfunction). - Functions: [the CEL function catalog](https://alvo.burgyn.online/reference/cel-functions/). - Problem types: [`function-failed`](https://alvo.burgyn.online/reference/problem-types/#function-failed), [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation). - Design notes: [host functions in `cel.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/cel.md#host-functions) and [registering a CEL function in `extensibility.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/extensibility.md#registering-a-cel-function). ## Next **[Running in production](https://alvo.burgyn.online/guides/production/)**: take a backend from a laptop to a server. --- # Handle errors Source: https://alvo.burgyn.online/guides/handle-errors/ :::tip[What you will achieve] A client that reacts to every refusal by its problem type, knows the four causes of a 403, and does not mistake an empty page or a 404 for an error in Alvo. About fifteen minutes, starting from the stack in [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/). ::: ## Before you start - The stack from [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/), run from its `alvo-help-desk` directory with `COMPOSE_FILE` and that page's secrets exported in this shell. - The `reader` key from [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/#3-narrow-a-key-with-scopes) (roles `agent`, `authenticated`; read scope only), with `ALVO_READER_KEY_SECRET` exported. - This page's descriptor: tickets that each caller sees only when they filed them, no `delete` rule, a hook that refuses to close a ticket without a resolution, and a hook that writes a slug of at most 40 characters: ```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 } }, "rules": { "list": "created_by == @user.id || 'admin' in @user.roles", "get": "created_by == @user.id || 'admin' in @user.roles", "create": "'agent' in @user.roles || 'admin' in @user.roles", "update": "created_by == @user.id || 'admin' in @user.roles" }, "hooks": { "beforeCreate": [ { "action": { "mutate": { "slug": { "$cel": "lowerAscii(replace(trim(new.title), ' ', '-'))" } } } } ], "beforeUpdate": [ { "condition": "new.status == 'closed' && !has(new.resolution)", "action": { "reject": "Close a ticket with a resolution: say how it was solved." } } ] } } } } ``` Start the stack over it. The first command **deletes the stack's database**: ```sh docker compose down --volumes curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/handle-errors/01-help-desk.alvo.json docker compose up --wait --wait-timeout 90 ``` The responses below were captured from a real host when the site was built, so your ids will differ. ## 1. Read a problem document Every refusal is an [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem document, sent as `application/problem+json`. Send a field the entity does not declare: ```sh 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","priority":"high"}' ``` ```http HTTP/1.1 422 Unprocessable Entity Content-Type: application/problem+json { "type": "https://alvo.dev/errors/validation", "title": "Unprocessable Entity", "status": 422, "detail": "The request body names a field that is not writable on this entity. Send only the fields the entity declares.", "violations": [ { "pointer": "/priority", "code": "unknown-field", "message": "The request body names a field that is not writable on this entity. Send only the fields the entity declares.", "fixSuggestion": "Remove the field, or check its spelling against the entity's declared fields." } ] } ``` | Member | What it is | Use it for | |---|---|---| | `type` | `https://alvo.dev/errors/`, one per kind of refusal | Branching in code. | | `status` | The HTTP status, repeated | Logging. | | `title` | The status's standard phrase | Nothing; it repeats `status`. | | `detail` | A sentence for a person | Showing or logging, never parsing. | | `violations` | One entry per reason, when there are reasons to itemise | Fixing the request. | Each violation has a `pointer`, a stable kebab-case `code`, a `message` and a `fixSuggestion`. A `pointer` that is empty or starts with `/` is a JSON pointer into the request body (`/priority`, `/rows/1/year`); any other value names the query parameter it is about (`filter`, `order`, `limit`, `offset`, `after`, `select`). No violation ever repeats a value you sent. A standalone host also adds `traceId`, the request's trace identifier; the responses on this site leave it out. ## 2. Branch on the slug, never on `detail` The slug at the end of `type` is the contract. `detail` is written for people and may change in any release; a violation's `code` is stable. Take the part after the last `/` and switch on it: | Slug | Status | What happened | What to do | |---|---|---|---| | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | 422 | The body breaks the entity's declared fields. | Fix each field a violation points at. | | [`malformed-query`](https://alvo.burgyn.online/reference/problem-types/#malformed-query) | 422 | The query string or query body is malformed. | Fix the parameter the violation names. | | [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated) | 401 | A key was sent and cannot be used. | Fix the key; retrying will not help. | | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | 403 | A rule or a before-hook refused. | Change the request, or call with a role the rules grant. | | [`out-of-scope`](https://alvo.burgyn.online/reference/problem-types/#out-of-scope) | 403 | The key's scopes do not cover this. | Grant the key the scope. | | [`not-found`](https://alvo.burgyn.online/reference/problem-types/#not-found) | 404 | No such row, or your rules hide it. | Check the id and the rules. | | [`conflict`](https://alvo.burgyn.online/reference/problem-types/#conflict) | 409 | A `unique` value or a `restrict` reference is in the way. | Send another value, or remove the reference. | | [`destructive-change`](https://alvo.burgyn.online/reference/problem-types/#destructive-change) | 409 | A Management API apply would discard data. | Resend with `allowDestructive: true`, or keep what the plan drops. | | [`idempotency-conflict`](https://alvo.burgyn.online/reference/problem-types/#idempotency-conflict) | 409 | The `Idempotency-Key` was used for a different request. | Send a new key. | | [`precondition-failed`](https://alvo.burgyn.online/reference/problem-types/#precondition-failed) | 412 | The row changed since you read it. | Read it again, reapply, resend. | | [`precondition-required`](https://alvo.burgyn.online/reference/problem-types/#precondition-required) | 428 | A Management API write carried no `If-Match` revision. | Read the current revision and send it. | | [`unsupported-media-type`](https://alvo.burgyn.online/reference/problem-types/#unsupported-media-type) | 415 | The body was not declared as JSON. | Send `Content-Type: application/json`. | | [`unreadable-request`](https://alvo.burgyn.online/reference/problem-types/#unreadable-request) | 413, 408, 400 | The web server refused the body. | Send a smaller body, in one go. | | [`function-failed`](https://alvo.burgyn.online/reference/problem-types/#function-failed) | 500 | A CEL function failed while the write was checked; nothing was written. | Check the input the function reads. | | [`internal`](https://alvo.burgyn.online/reference/problem-types/#internal) | 500 | Something inside Alvo broke. | Nothing in the request is at fault; the host's log has the details. | The `type` URIs do not resolve yet. Each slug is an anchor on the [Problem types](https://alvo.burgyn.online/reference/problem-types/) page: `https://alvo.dev/errors/forbidden` is [`/reference/problem-types/#forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden). An embedded host can compare against the public constants in `AlvoProblemTypes` instead of copying strings. ## 3. Know the four causes of a 403 The Data API's status catalogue names four causes of a 403, and three of them share the slug `forbidden`. **A rule refused.** The operation has no rule, the row a write would store fails the rule, or the rule reads `@user.id` or `@tenant.id` and the caller has neither, or the entity is tenant-scoped and the caller has no tenant (refused before any rule runs). The `detail` never says which rule. This descriptor has no `delete` rule for tickets, so even an administrator is refused: ```sh curl -sS -X DELETE http://localhost:8080/api/tickets/44db3945-cd18-434d-8ab3-6a39ffbe79b3 \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json { "type": "https://alvo.dev/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "No policy allows 'delete' on this entity." } ``` A request without a key is judged by the rules too. These rules read `@user.id`, which such a caller lacks: ```sh curl -sS -X GET http://localhost:8080/api/tickets ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json { "type": "https://alvo.dev/errors/forbidden", "title": "Forbidden", "status": 403, "detail": "The caller has no identity, and the policy for this operation reads one." } ``` **A before-hook's `reject` fired.** Its text is the `detail`, followed by the hook's place in the descriptor. Create a ticket, then close it without a resolution: ```sh 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"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155228806970" Location: /api/tickets/44db3945-cd18-434d-8ab3-6a39ffbe79b3 { "id": "44db3945-cd18-434d-8ab3-6a39ffbe79b3", "title": "Printer on fire", "status": "open", "slug": "printer-on-fire" } ``` ```sh curl -sS -X PATCH http://localhost:8080/api/tickets/44db3945-cd18-434d-8ab3-6a39ffbe79b3 \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"status":"closed"}' ``` ```http 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')" } ``` **A before-hook computed a value its field cannot hold.** The slug of a long title is longer than 40 characters. The caller never sent `slug`, so this is not a 422: the `detail` names the hook, the field and the facet, never the value: ```sh 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"}' ``` ```http 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 key's scopes do not cover the operation.** This is the one 403 with its own slug, because its fix is different: change the key, not a rule. The `reader` key may read and not write: ```sh curl -sS -X POST http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: reader.$ALVO_READER_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"title":"Printer on fire"}' ``` ```http HTTP/1.1 403 Forbidden Content-Type: application/problem+json { "type": "https://alvo.dev/errors/out-of-scope", "title": "Forbidden", "status": 403, "detail": "The presented API key's scopes do not permit this operation. Grant the key the scope it needs." } ``` The Management API also answers `forbidden` when a caller lacks the project access level a route needs; see [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/). Writing a computed field is also a 403 `forbidden`, while writing a `readOnly` field is a 422 `validation` ([#344](https://github.com/Burgyn/MMLib.Alvo/issues/344)). ## 4. An empty page is not a 403 A `list` rule is a filter, not a gate. A caller whose rule matches no row gets 200 and an empty page, the way PostgreSQL row-level security behaves. The reader has filed no tickets, so its list is empty even though one exists: ```sh curl -sS -X GET http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: reader.$ALVO_READER_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [], "next": null, "count": null } ``` So: if you wrote a rule and a caller gets an empty page, it is your rule. If a caller gets a 403 on a read, it is one of the causes in step 3, not the condition you wrote. [Access rules](https://alvo.burgyn.online/guides/access-rules/#3-know-when-alvo-answers-403) has the full table. ## 5. A 404 can be your rules The `get`, `update` and `delete` rules filter rows the same way, so a row the caller may not see is answered exactly like a row that does not exist. The administrator files a ticket; the agent asks for it by id: ```sh curl -sS -X POST http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"title":"Renew the TLS certificate"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155229340680" Location: /api/tickets/36458b27-bd1a-4f91-ae53-b9e7d55ad9e8 { "id": "36458b27-bd1a-4f91-ae53-b9e7d55ad9e8", "title": "Renew the TLS certificate", "created_by": "8d4e2a6c-1b3f-4c5d-8e7a-9f0b1c2d3e02" } ``` ```sh curl -sS -X GET http://localhost:8080/api/tickets/36458b27-bd1a-4f91-ae53-b9e7d55ad9e8 \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" ``` ```http HTTP/1.1 404 Not Found Content-Type: application/problem+json { "type": "https://alvo.dev/errors/not-found", "title": "Not Found", "status": 404, "detail": "The requested record was not found." } ``` The two cases are one answer on purpose: a different answer would tell anyone holding an id that the row exists. To check a rule without a request as that caller, ask the Management API's `policy/simulate` ([Access rules](https://alvo.burgyn.online/guides/access-rules/#5-test-a-rule-with-policysimulate)). ## 6. A 415 is a missing header A body that is not declared as `application/json`, or a request with no `Content-Type` at all, is refused before the body is read. Nothing in the body is wrong, so there are no `violations`; the header in the answer names the accepted type: ```sh curl -sS -X POST http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \ -H "Content-Type: text/plain" \ -d '{"title":"Printer on fire"}' ``` ```http HTTP/1.1 415 Unsupported Media Type Content-Type: application/problem+json Accept-Post: application/json { "type": "https://alvo.dev/errors/unsupported-media-type", "title": "Unsupported Media Type", "status": 415, "detail": "This endpoint reads a JSON request body. Send it with 'Content-Type: application/json'." } ``` The fix is the header. Many HTTP clients send `text/plain` when you pass a string as the body, or no type at all; set the header yourself. [Write data safely](https://alvo.burgyn.online/guides/write-data/#5-send-json-and-say-so) explains why the requirement exists. ## 7. A 401 means the key, not the caller `unauthenticated` means a key was sent and cannot be used: a wrong secret, a revoked or expired key, a key not issued for the requested tenant, or a key with a role the descriptor does not declare. A request without any key is not a 401; the rules judge it, as step 3 showed. ```sh curl -sS -X GET http://localhost:8080/api/tickets \ -H "X-Alvo-Api-Key: agent.this-is-not-the-secret-of-this-key" ``` ```http HTTP/1.1 401 Unauthorized Content-Type: application/problem+json { "type": "https://alvo.dev/errors/unauthenticated", "title": "Unauthorized", "status": 401, "detail": "The presented API key could not be used. Check the key, whether it has been revoked or has expired, and whether it was issued for the tenant you requested." } ``` ## Errors in an embedded host Three problem types come from Alvo's exception handler rather than from an endpoint: `unreadable-request`, `internal` and `function-failed`. The standalone host registers that handler: ```csharp builder.Services.AddAlvoProblemDetails(); ``` ```csharp app.UseExceptionHandler(); ``` An embedded host decides for itself. With both calls, those three failures on Alvo's own routes get the documents above, and a failure on the host's own routes is left to the host's handlers. Without them, which is the default, Alvo writes nothing for them: the exception reaches your own error handling and logging, and your app renders the 500 or 400 it always would. Every other problem type on this page comes from the endpoints themselves and is the same in both modes. Your own endpoints that call `IAlvoData` get exceptions rather than problem documents, one exception type per family above; [Call Alvo from your endpoints](https://alvo.burgyn.online/guides/call-from-endpoints/#3-render-what-ialvodata-refuses) shows how to render them. ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 422 | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | The body names an unknown field, misses a required one or breaks a facet. | Fix each field a violation points at. | every host | | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | A rule refused, a `reject` fired, or a hook's value broke a facet. | Read the `detail`; change the request or the descriptor. | every host | | 403 | [`out-of-scope`](https://alvo.burgyn.online/reference/problem-types/#out-of-scope) | The key's scopes do not cover the operation. | Grant the key the scope. | every host | | 404 | [`not-found`](https://alvo.burgyn.online/reference/problem-types/#not-found) | The row does not exist, or the rules hide it. | Check the id, then the rule with `policy/simulate`. | every host | | 415 | [`unsupported-media-type`](https://alvo.burgyn.online/reference/problem-types/#unsupported-media-type) | The body is not declared as JSON. | Send `Content-Type: application/json`. | every host | | 401 | [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated) | The key cannot be used. | Check the secret and the key's roles. | every host | | 413, 408, 400 | [`unreadable-request`](https://alvo.burgyn.online/reference/problem-types/#unreadable-request) | The web server refused the body before Alvo read it. | Send a smaller body, in one go. | standalone; embedded only with `AddAlvoProblemDetails()` | | 500 | [`function-failed`](https://alvo.burgyn.online/reference/problem-types/#function-failed) | A CEL function failed during a write. | Check the function's input; the exception is in the host's log. | standalone; embedded only with `AddAlvoProblemDetails()` | | 500 | [`internal`](https://alvo.burgyn.online/reference/problem-types/#internal) | An invariant inside Alvo broke. | Read the host's log. | standalone; embedded only with `AddAlvoProblemDetails()` | ## Reference - [Problem types](https://alvo.burgyn.online/reference/problem-types/): every slug, its causes, fix and violation codes. - C# API: [`AddAlvoProblemDetails`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo/#addalvoproblemdetails). - Design notes: [the status and slug catalogue](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#the-status-and-type-slug-catalogue), [the RLS surprise](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#the-rls-surprise-a-configured-rule-that-excludes-you-is-200-with-an-empty-page-not-403) and [what Alvo treats as confidential](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#position-a-the-declared-schema-shape-is-public-data-and-a-hidden-fields-name-are-not). ## Next **[Use your own authentication](https://alvo.burgyn.online/guides/own-authentication/)**: let your app's signed-in users reach Alvo's data under the descriptor's rules. --- # Use your own authentication Source: https://alvo.burgyn.online/guides/own-authentication/ :::tip[What you will achieve] Your app's own signed-in users reach Alvo's data through your endpoints, and the descriptor's rules decide what each of them may do: an inspector repaints a vehicle, a clerk cannot. About fifteen minutes, starting from an app that embeds Alvo as in [Embed Alvo in ASP.NET Core](https://alvo.burgyn.online/start-here/embed/). ::: ## Before you start - An app that embeds Alvo as in [Embed Alvo in ASP.NET Core](https://alvo.burgyn.online/start-here/embed/). This page walks through one `Program.cs`; to try it, step 4 runs the repository's embedded sample, which has the same endpoints, from a clone with the .NET 10 SDK, plus `curl` and `jq`. - Its descriptor, [`examples/vehicle-registry`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/vehicle-registry/vehicles.alvo.json), the same one [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/) serves from Docker. It lets `admin` and `inspector` update a vehicle and every authenticated caller read one. ## 1. Two surfaces, one backend An embedded host serves the same data to two kinds of caller, and they authenticate differently: | Path | Who | How they reach the data | |---|---|---| | `/app/*` | your app's own users, signed in with your app's cookie | your endpoints call `IAlvoData` with an `AlvoContext` they build from the user's claims | | `/api/alvo/*` | agents and machines, with `X-Alvo-Api-Key` | Alvo's generated Data API, mapped by `MapAlvoDataApi()` | | `/health/live`, `/health/ready` | container probes | `MapAlvoHealth()` | Both go through the same rule engine. Your endpoints contain no authorization logic: they say who the caller is, and the descriptor's `rules` decide. ## 2. Sign your users in, your way Alvo adds no authentication middleware to your app. This one signs its users in with an ordinary ASP.NET Core cookie, and sets the cookie's options explicitly: `SameSite=Lax` keeps a cross-site form from carrying it, `Secure` is required outside development, and a session ends after eight hours. ```csharp builder.Services .AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.Cookie.Name = "fleet-desk"; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = false; }); builder.Services.AddAuthorization(); ``` Its sign-in endpoint, `/app/login`, exists only in the `Development` environment and checks no password: it takes a demo user's name and reads that user's roles from a table on the server. It never takes a role list from the request, because a sign-in that lets callers name their own roles lets anyone become an administrator: ```csharp var demoUsers = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["inspector"] = (Guid.Parse("3f6b9c21-5a4d-4e88-9b2f-7c1a0d5e6f30"), ["inspector"]), ["clerk"] = (Guid.Parse("b8a45d17-2e93-4c60-8f1d-6a2b3c4d5e6f"), []), }; app.MapPost("/app/login", (LoginRequest request) => { if (!demoUsers.TryGetValue(request.User, out var user)) { return Results.NotFound(new { known = demoUsers.Keys }); } var identity = new ClaimsIdentity( [new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), .. user.Roles.Select(role => new Claim(ClaimTypes.Role, role))], CookieAuthenticationDefaults.AuthenticationScheme); return Results.SignIn(new ClaimsPrincipal(identity), authenticationScheme: CookieAuthenticationDefaults.AuthenticationScheme); ``` Replace it with your own identity provider. What has to survive the replacement is that **the roles come from somewhere the caller does not control**. ## 3. Turn a signed-in user into an `AlvoContext` Every `/app` endpoint starts by building the caller Alvo will judge: ```csharp if (roles.DeclaredRoles is not { } catalog) { return Results.Problem(detail: "Alvo has not applied a descriptor yet.", statusCode: StatusCodes.Status503ServiceUnavailable); } if (!Guid.TryParse(http.User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId)) { return Results.Unauthorized(); } var caller = new AlvoContext { User = new UserId(userId), Roles = catalog.Resolve(["authenticated", .. http.User.FindAll(ClaimTypes.Role).Select(c => c.Value).Where(r => catalog.TryGet(r, out _))]), }; ``` - **Roles go through the role catalog.** `IRoleCatalogProvider.DeclaredRoles` holds the three built-in roles plus the applied descriptor's `auth.roles`. `RoleCatalog.Resolve` mints roles only from that set and throws `UnknownRoleException` for any other name, so a typo is refused where it arrives instead of silently matching no rule. The code filters your app's roles with `TryGet` first: a role your app knows and the descriptor does not is dropped, because only the overlap means anything to Alvo's rules. - **`authenticated` is added for every signed-in user**, because the descriptor's read rules key on it. - **No descriptor yet is a 503, not a 401.** `DeclaredRoles` is `null` until Alvo has applied the descriptor at start, which is a boot problem rather than a sign-in problem. - **No tenant here, because this descriptor declares no tenancy.** Over a tenant-scoped entity, set `AlvoContext.Tenant` from your user's tenant, or every request is refused before any rule runs ([Multi-tenancy](https://alvo.burgyn.online/guides/multi-tenancy/)). [Call Alvo from your endpoints](https://alvo.burgyn.online/guides/call-from-endpoints/) shows the endpoints that use it. ## 4. Try it Start the sample in the background over a fresh database. The dev key's secret comes from an environment variable, because the sample ships no credential: ```sh 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:DatabasePath "$(mktemp -d)/fleet-desk.db" & until curl -sf localhost:5199/health/ready > /dev/null; do sleep 1; done ``` Create a vehicle with the API key, sign in as the two demo users, and let each repaint it: ```sh 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) VEHICLE=$(curl -s -X POST localhost:5199/api/alvo/vehicles -H "X-Alvo-Api-Key: $KEY" \ -H "Content-Type: application/json" \ -d '{"vin":"TMBJJ7NE8L0123456","plate":"BA-101AA","make":"Skoda","model":"Octavia","year":2020,"owner_id":"'"$OWNER"'"}' \ | jq -r .id) curl -s -c inspector.cookies -X POST localhost:5199/app/login \ -H "Content-Type: application/json" -d '{"user":"inspector"}' curl -s -c clerk.cookies -X POST localhost:5199/app/login \ -H "Content-Type: application/json" -d '{"user":"clerk"}' curl -s -b inspector.cookies -X PATCH "localhost:5199/app/vehicles/$VEHICLE" \ -H "Content-Type: application/json" -d '{"color":"red"}' -w ' %{http_code}\n' curl -s -b clerk.cookies -X PATCH "localhost:5199/app/vehicles/$VEHICLE" \ -H "Content-Type: application/json" -d '{"color":"blue"}' -w ' %{http_code}\n' ``` The inspector's `PATCH` answers 200 with the repainted vehicle, and its `updated_by` is the inspector's user id, the `Id` from the table in step 2. The clerk's answers 404 with no body: the `update` rule is a row filter, so for a caller it excludes the row is not there, the same answer a key would get from the generated API. The sample contains no code for either decision. A request with no cookie at all never reaches Alvo; the app's own authorization answers it, with a redirect to a sign-in page. Stop the sample with `kill %1`. ## How it works `IAlvoData` takes the caller as an explicit `AlvoContext` parameter on every call, so your endpoint states who it acts as and Alvo enforces the rules inside the call, over the same compiled policy the generated API uses. The generated routes resolve their own caller from the API-key header and ignore whatever your middleware does. [Standalone and embedded](https://alvo.burgyn.online/concepts/modes/) compares the two modes. :::caution[Not in this build] A host cannot publish its own signed-in user to the generated `/api/alvo` routes. Each generated route resolves the caller from the credential header itself and clears it afterwards, so a value your middleware writes to `IAlvoContextAccessor` is discarded before the endpoint runs. Your users therefore go through your own endpoints. The seam a host needs is planned for a later phase ([#210](https://github.com/Burgyn/MMLib.Alvo/issues/210)). ::: ## Never read Alvo's credential from a cookie `Alvo:Auth:HeaderName` names the header the generated routes read their API key from, and it is plain configuration. Pointing it at `Cookie`, together with a custom `IAlvoContextResolver`, would make the browser authenticate Alvo's routes for you, and would make every one of them a cross-site request forgery target: a browser attaches cookies to cross-site requests by itself. So Alvo refuses that configuration at startup: ```sh Alvo__Auth__DevKeys__0__Secret="$FLEET_DESK_KEY_SECRET" Alvo__Auth__HeaderName=Cookie \ dotnet run --project samples/MMLib.Alvo.Samples.EmbeddedHost ``` The host stops before it listens, with an `OptionsValidationException` whose message says that `Alvo:Auth:HeaderName` is `Cookie`, why that is refused, that the default is `X-Alvo-Api-Key`, and to resolve your users in your own endpoints instead. `Alvo:Auth:TenantHeaderName` is refused the same way. `Authorization` is allowed: a browser does not attach it to a cross-site request unless the user has signed in to that site with HTTP authentication, and script cannot set it cross-origin without the server's consent. The JSON `Content-Type` requirement on every request with a body is the other half of that defence, and it has no switch ([Write data safely](https://alvo.burgyn.online/guides/write-data/#5-send-json-and-say-so)). Your own endpoints are yours to protect: a cookie-authenticated `/app` route that changes data needs the same care as any other in your app. ## What can go wrong | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 401 | [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated) | An API key sent to `/api/alvo` cannot be used. | Use the configured secret; give the key only roles the descriptor declares. | every host | | 403 | none, your app's own | A rule or a before-hook refused (`AlvoAuthorizationException`); the code above renders it as a problem document with the exception's message. | Check the rule against the user's roles. | your endpoints | | 404 | none, your app's own | A rule excludes the row for this user (`AlvoRecordNotFoundException`). | Check the rule and the user's roles. | your endpoints | | 503 | none, your app's own | `DeclaredRoles` is `null`: no descriptor has been applied yet. | Wait for `/health/ready`; read the log if it never turns ready. | your endpoints | | none | none | The host stops at start: `Alvo:Auth:HeaderName` or `TenantHeaderName` is `Cookie`, or a dev key's secret is missing or shorter than 32 characters. | Use a header only script can set; supply a secret of at least 32 characters (`openssl rand -hex 16`). | startup | ## Reference - C# API: [`IAlvoData`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-abstractions/#ialvodata), [`IAlvoContextAccessor`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-abstractions/#ialvocontextaccessor), [`IAlvoContextResolver`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-abstractions/#ialvocontextresolver). - Configuration: [`Alvo:Auth`](https://alvo.burgyn.online/reference/configuration/#alvoauth). - The runnable sample step 4 starts: [`samples/MMLib.Alvo.Samples.EmbeddedHost`](https://github.com/Burgyn/MMLib.Alvo/tree/main/samples/MMLib.Alvo.Samples.EmbeddedHost). - Design notes: [the identity seam in `extensibility.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/extensibility.md#the-runnable-example) and [why a media type is a CSRF control](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#why-a-media-type-is-a-csrf-control-at-all). ## Next **[Call Alvo from your endpoints](https://alvo.burgyn.online/guides/call-from-endpoints/)**: the `/app` endpoints themselves, and how to mount the generated API beside them. --- # Read data: filter, sort, page Source: https://alvo.burgyn.online/guides/read-data/ :::tip[What you will achieve] A list request that returns exactly the rows and fields you asked for, in the order you asked for, one page at a time, with a total count when you want one. About fifteen minutes, starting from the vehicle-registry stack of [Quick start](https://alvo.burgyn.online/start-here/quick-start/). ::: ## Before you start - The stack from [Quick start](https://alvo.burgyn.online/start-here/quick-start/), serving [`examples/vehicle-registry`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/vehicle-registry/vehicles.alvo.json), run from the directory that holds `docker-compose.quickstart.yml`, with `ALVO_DEMO_KEY_SECRET` and `ALVO_ADMIN_PASSWORD` exported in this shell. - The `demo` key (roles `admin`, `authenticated`). Any authenticated caller may list vehicles. Start from an empty database so your results match the ones below. The first command **deletes the stack's database**: ```sh docker compose -f docker-compose.quickstart.yml down --volumes unset ALVO_DESCRIPTOR docker compose -f docker-compose.quickstart.yml up --wait --wait-timeout 90 ``` The responses below were captured from a real host when the site was built, so your ids, timestamps and cursors will differ. ## 1. Add rows to query Create an owner under an id you choose, then five vehicles in one request: ```sh curl -sS -X PUT http://localhost:8080/api/owners/2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Fleet Desk Ltd","email":"office@fleetdesk.example"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155274969830" Location: /api/owners/2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d { "id": "2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d", "name": "Fleet Desk Ltd", "email": "office@fleetdesk.example" } ``` ```sh curl -sS -X POST http://localhost:8080/api/vehicles/batch \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"rows":[{"vin":"TMBJJ7NE8L0123456","plate":"BA-101AA","make":"Skoda","model":"Octavia","year":2020,"color":"red","owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"},{"vin":"TMBEG7NJ1N0234567","plate":"BA-202BB","make":"Skoda","model":"Fabia","year":2022,"color":"blue","owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"},{"vin":"WVWZZZAUZKW345678","plate":"KE-303CC","make":"Volkswagen","model":"Golf","year":2019,"color":"red","owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"},{"vin":"WVWZZZ3CZPE456789","plate":"KE-404DD","make":"Volkswagen","model":"Passat","year":2023,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"},{"vin":"VF1RJA00X67567890","plate":"ZA-505EE","make":"Renault","model":"Clio","year":2017,"color":"white","owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}]}' ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "affected": 5 } ``` `PUT` with an id creates the row when it does not exist, and the batch writes every row or none. [Write data safely](https://alvo.burgyn.online/guides/write-data/) covers both. ## 2. Filter rows A filter is a query parameter named after a field: `?=.`. Every parameter must hold, and a field may appear more than once (`year=gte.2019&year=lte.2022`). The examples add `select` to keep the responses short; step 4 explains it. ```sh curl -sS -X GET 'http://localhost:8080/api/vehicles?make=eq.Skoda&year=gte.2021&select=plate,make,model,year' \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "plate": "BA-202BB", "make": "Skoda", "model": "Fabia", "year": 2022 } ], "next": null, "count": null } ``` `or=(…)` matches when any of its terms holds, and `and=(…)` groups terms that must all hold. Inside a group, a term is written `field.operator.value`: ```sh curl -sS -X GET 'http://localhost:8080/api/vehicles?or=(color.eq.red,color.eq.blue)&select=plate,make,color' \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "plate": "BA-202BB", "make": "Skoda", "color": "blue" }, { "plate": "KE-303CC", "make": "Volkswagen", "color": "red" }, { "plate": "BA-101AA", "make": "Skoda", "color": "red" } ], "next": null, "count": null } ``` `is.null` finds rows where a field has no value: ```sh curl -sS -X GET 'http://localhost:8080/api/vehicles?color=is.null&select=plate,color' \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "plate": "KE-404DD", "color": null } ], "next": null, "count": null } ``` `like` and `ilike` match a pattern: `%` stands for any run of characters and `_` for one, and `ilike` ignores case. In a URL, `%` is written `%25`: ```sh curl -sS -X GET 'http://localhost:8080/api/vehicles?model=ilike.%25a%25&select=plate,model' \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "plate": "KE-404DD", "model": "Passat" }, { "plate": "BA-202BB", "model": "Fabia" }, { "plate": "BA-101AA", "model": "Octavia" } ], "next": null, "count": null } ``` To negate a term, put `not.` in front of the parameter name (`not.make=eq.Skoda`) or in front of a group member (`or=(not.color.eq.red,…)`). PostgREST's spelling, `make=not.eq.Skoda`, is refused with `unknown-operator` ([#350](https://github.com/Burgyn/MMLib.Alvo/issues/350)): ```sh curl -sS -X GET 'http://localhost:8080/api/vehicles?not.make=eq.Skoda&or=(not.color.eq.red,year.gte.2023)&select=plate,make,color,year' \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "plate": "KE-404DD", "make": "Volkswagen", "color": null, "year": 2023 }, { "plate": "ZA-505EE", "make": "Renault", "color": "white", "year": 2017 } ], "next": null, "count": null } ``` Each term takes one operator from this list, and the field's type decides which ones it accepts: | Operator | Matches | Field types | |---|---|---| | `eq`, `neq` | equal, not equal | every type | | `in` | one of a list: `in.(Skoda,Renault)` | every type | | `gt`, `gte`, `lt`, `lte` | greater, at least, less, at most | `string`, `text`, `enum`, `integer`, `decimal`, `date`, `datetime` | | `like`, `ilike` | a pattern, case-sensitive or not | `string`, `text`, `enum` | | `is` | `null`; `true` or `false` on a `boolean` | every type for `null` | A parameter that is not a field is refused, never ignored, so a typo such as `?oder=year` cannot quietly return rows in some other order. That is also why `order`, `limit`, `offset`, `after`, `select`, `or`, `and` and `not` are reserved: a descriptor cannot declare a field with one of those names. An operator the field's type does not take is refused too: ```sh curl -sS -X GET 'http://localhost:8080/api/vehicles?year=like.20%25' \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 422 Unprocessable Entity Content-Type: application/problem+json { "type": "https://alvo.dev/errors/malformed-query", "title": "Unprocessable Entity", "status": 422, "detail": "A filter applies an operator the type of the field it names does not support.", "violations": [ { "pointer": "filter", "code": "unsupported-operator-for-field", "message": "A filter applies an operator the type of the field it names does not support.", "fixSuggestion": "'like' cannot be applied to a 'integer' field: like/ilike are string pattern matches, and gt/gte/lt/lte need a type this port orders." } ] } ``` ## 3. Sort, and choose where nulls go `order` takes a comma-separated list of fields, each with an optional `.asc` (the default) or `.desc`, and an optional `.nullsfirst` or `.nullslast`. Rows without a value sort last unless you say otherwise. Colours in reverse alphabetical order, vehicles without a colour first, ties broken by year: ```sh curl -sS -X GET 'http://localhost:8080/api/vehicles?order=color.desc.nullsfirst,year&select=plate,color,year' \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "plate": "KE-404DD", "color": null, "year": 2023 }, { "plate": "ZA-505EE", "color": "white", "year": 2017 }, { "plate": "KE-303CC", "color": "red", "year": 2019 }, { "plate": "BA-101AA", "color": "red", "year": 2020 }, { "plate": "BA-202BB", "color": "blue", "year": 2022 } ], "next": null, "count": null } ``` Alvo decides where a `null` sorts, not the database, so SQLite and PostgreSQL return the same order. Sorting by a field that can be empty costs more than sorting by a `required` one, because no index serves it yet ([#178](https://github.com/Burgyn/MMLib.Alvo/issues/178)). ## 4. Choose the fields: `select` `select` names the fields each row carries, and the database stops reading the others. `alias:field` renames a field in the response: ```sh curl -sS -X GET 'http://localhost:8080/api/vehicles?select=plate,label:make' \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "plate": "KE-404DD", "label": "Volkswagen" }, { "plate": "BA-202BB", "label": "Skoda" }, { "plate": "KE-303CC", "label": "Volkswagen" }, { "plate": "BA-101AA", "label": "Skoda" }, { "plate": "ZA-505EE", "label": "Renault" } ], "next": null, "count": null } ``` Without `select`, a row carries every field the caller may read, including the columns Alvo manages: `id`, and with `audit` the `created_*` and `updated_*` stamps. A field that is `hidden` for the caller is refused in `select`, in a filter and in `order` exactly like a field that does not exist. ## 5. Page through the results Every list is paged. Without `limit` a page holds 50 rows; `limit` may ask for up to 200, and a larger value is refused rather than quietly reduced ([Limits and budgets](https://alvo.burgyn.online/reference/limits/)). The response always carries `items`, `next` and `count`: ```sh curl -sS -X GET 'http://localhost:8080/api/vehicles?order=year.desc&limit=2&select=plate,year' \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "plate": "KE-404DD", "year": 2023 }, { "plate": "BA-202BB", "year": 2022 } ], "next": "S-8JN-ye3UuFew6ckFY0Yg", "count": null } ``` `next` is a cursor for the following page. Send the same request again with `after` set to it: ```sh curl -sS -X GET 'http://localhost:8080/api/vehicles?order=year.desc&limit=2&select=plate,year&after=S-8JN-ye3UuFew6ckFY0Yg' \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "plate": "BA-101AA", "year": 2020 }, { "plate": "KE-303CC", "year": 2019 } ], "next": "HOHIha0euUyNN4pNyq-Kew", "count": null } ``` On the last page `next` is `null`. Treat the cursor as an opaque string of at most 512 characters: it carries no data, and a stale or forged one returns an empty page; an empty one, or one over 512 characters, is refused (`invalid-cursor`). Cursor paging neither skips nor repeats a row while other callers write. `offset=` is the alternative, but a request may not send both `after` and `offset`. ## 6. Count the matches Send `Prefer: count=exact` to fill `count` with the number of rows the whole query matches, not the number on this page. `Preference-Applied` confirms it: ```sh curl -sS -X GET 'http://localhost:8080/api/vehicles?make=eq.Skoda&limit=1&select=plate' \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Prefer: count=exact" ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 Preference-Applied: count=exact { "items": [ { "plate": "BA-202BB" } ], "next": "S-8JN-ye3UuFew6ckFY0Yg", "count": 2 } ``` A count is a second query over every matching row, so ask for it only when you show it. It counts only the rows your rules let you see. Because it runs beside the page rather than in the same statement, a write in between can make it differ from the rows by one. `count=planned` and `count=estimated` are accepted and return the exact count; any other preference is ignored, and `Preference-Applied` is then absent. ## 7. Send a long query as a body Proxies limit a URL to a few kilobytes, and a filter over a few hundred ids is longer than that. `POST /api//query` takes the same parameters as a JSON object, each value written exactly as it would follow the `=` in the URL: ```sh curl -sS -X POST http://localhost:8080/api/vehicles/query \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"make":"in.(Skoda,Renault)","model":"ilike.%a%","order":"year","select":"plate,make,model,year"}' ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "plate": "BA-101AA", "make": "Skoda", "model": "Octavia", "year": 2020 }, { "plate": "BA-202BB", "make": "Skoda", "model": "Fabia", "year": 2022 } ], "next": null, "count": null } ``` The body carries decoded values, so `%` is just `%` here, not `%25`. To repeat a parameter, give it an array of values. It is still a read: the caller needs the `list` rule, `Prefer: count=exact` works, and like every request with a body it needs `Content-Type: application/json`. ## How it works Alvo compiles the query together with the entity's `list` rule into one SQL statement, with every value bound as a parameter. The rule is part of the `WHERE` clause, so a row your rule excludes is never in a page, never counted and never reachable through a cursor. That is why a caller the rule shuts out gets an empty page rather than a 403; [Security model](https://alvo.burgyn.online/concepts/security-model/) explains the design. ## Options and variations | Parameter | Example | Notes | |---|---|---| | a field name | `year=gte.2020` | Repeat it for a range: `year=gte.2019&year=lte.2022`. | | `not.` + a field name | `not.make=eq.Skoda` | One `not.` only; `not.not.` is refused. | | `or`, `and` | `or=(color.eq.red,color.eq.blue)` | Nest a group with `=` inside it: `or=(make.eq.Skoda,and=(year.gte.2020,color.eq.red))`. | | `order` | `order=year.desc.nullslast,plate` | Several keys, comma-separated. | | `select` | `select=plate,label:make` | At most as many keys as you can read fields. | | `limit` | `limit=100` | 1 to 200, default 50. | | `after` | `after=` | The cursor from the previous page. | | `offset` | `offset=100` | Not together with `after`. | The full grammar, headers and route shapes are in [Data API conventions](https://alvo.burgyn.online/data-api/conventions/), and the routes and fields this descriptor generates are in [Data API: example](https://alvo.burgyn.online/reference/data-api/). :::caution[Not in this build] `gt`, `gte`, `lt` and `lte` are refused on `uuid`, `ref` and `boolean` fields, so the common `id=gt.` paging idiom does not work; use `after` instead ([#95](https://github.com/Burgyn/MMLib.Alvo/issues/95)). On a sort over several fields, the cost of a page grows the deeper you page ([#100](https://github.com/Burgyn/MMLib.Alvo/issues/100)). Neither appears on [Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/), which lists descriptor blocks only. ::: ## What can go wrong A malformed query is refused with every reason at once, each pointing at the parameter it is about: ```sh curl -sS -X GET 'http://localhost:8080/api/vehicles?colour=eq.red&limit=500' \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" ``` ```http HTTP/1.1 422 Unprocessable Entity Content-Type: application/problem+json { "type": "https://alvo.dev/errors/malformed-query", "title": "Unprocessable Entity", "status": 422, "detail": "The query references a field that is not available to this caller. The requested page size is not a whole number between 1 and the maximum this API allows.", "violations": [ { "pointer": "filter", "code": "unavailable-field", "message": "The query references a field that is not available to this caller.", "fixSuggestion": "Name a field this entity declares and your policy lets you read. The reserved query parameters are order, limit, offset, after, select, or, and, not." }, { "pointer": "limit", "code": "invalid-page-size", "message": "The requested page size is not a whole number between 1 and the maximum this API allows.", "fixSuggestion": "Ask for between 1 and 200 rows. A larger request is refused rather than quietly reduced, because a clamped page makes a client's own paging arithmetic wrong." } ] } ``` | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 422 | [`malformed-query`](https://alvo.burgyn.online/reference/problem-types/#malformed-query) | A parameter names no readable field, an operator does not fit the field's type, `limit` is over 200, `after` and `offset` are both sent, `after` is empty or over 512 characters, or a filter is deeper or wider than the [limits](https://alvo.burgyn.online/reference/limits/). | Fix the parameter each violation's `pointer` names. | every host | | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | The entity has no `list` rule, or the rule reads `@user.id` and the request carries no key. | Add the rule, or send a key. | every host | | 403 | [`out-of-scope`](https://alvo.burgyn.online/reference/problem-types/#out-of-scope) | The key's scopes do not cover reading this entity. | Grant the key a read scope. | every host | | 401 | [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated) | The key cannot be used. | See [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/). | every host | | 404 | [`not-found`](https://alvo.burgyn.online/reference/problem-types/#not-found) | `GET /api//` names a row that does not exist, or the `get` rule hides it. | Check the id and the rule. | every host | | 414 | none | A proxy refused a URL that was too long, before Alvo saw it. | Send the query as a body to `POST …/query`. | a proxy, not Alvo | An empty page is not an error: either nothing matches, or your `list` rule excludes the rows. [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) tells the two apart. ## Reference - [Data API conventions](https://alvo.burgyn.online/data-api/conventions/): the grammar, paging and headers, one table each. - [Limits and budgets](https://alvo.burgyn.online/reference/limits/): page size, filter depth and terms, `in` candidates, body size. - Problem types: [`malformed-query`](https://alvo.burgyn.online/reference/problem-types/#malformed-query), [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden), [`out-of-scope`](https://alvo.burgyn.online/reference/problem-types/#out-of-scope), [`unauthenticated`](https://alvo.burgyn.online/reference/problem-types/#unauthenticated). - Design notes: [the URL grammar and its allow-lists](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#the-url-grammar-and-the-two-allow-lists-that-bound-it), [keyset paging](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#paging-keyset-over-an-opaque-cursor-and-its-real-cost) and [the opt-in count](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#the-count-is-opt-in-prefer-countexact-110). ## Next **[Write data safely](https://alvo.burgyn.online/guides/write-data/)**: create, replace and update rows without losing a concurrent change, and retry a create without writing it twice. --- # Write data safely Source: https://alvo.burgyn.online/guides/write-data/ :::tip[What you will achieve] Writes that a network retry cannot duplicate, updates that refuse to overwrite a change you have not seen, and a batch that writes every row or none. About fifteen minutes, starting from the vehicle-registry stack of [Quick start](https://alvo.burgyn.online/start-here/quick-start/). ::: ## Before you start - The stack from [Quick start](https://alvo.burgyn.online/start-here/quick-start/), serving [`examples/vehicle-registry`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/vehicle-registry/vehicles.alvo.json), run from the directory that holds `docker-compose.quickstart.yml`, with `ALVO_DEMO_KEY_SECRET` and `ALVO_ADMIN_PASSWORD` exported in this shell. - The `demo` key (roles `admin`, `authenticated`). Only `admin` may create owners and vehicles. Start from an empty database so your results match the ones below. The first command **deletes the stack's database**: ```sh docker compose -f docker-compose.quickstart.yml down --volumes unset ALVO_DESCRIPTOR docker compose -f docker-compose.quickstart.yml up --wait --wait-timeout 90 ``` The responses below were captured from a real host when the site was built, so your ids, timestamps and ETags will differ. ## 1. Create or replace a row with `PUT` `POST /api/` creates a row and Alvo picks its id. `PUT /api//` uses the id you choose: it creates the row when it does not exist (201, with `Location`) and replaces it when it does (200): ```sh curl -sS -X PUT http://localhost:8080/api/owners/2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Fleet Desk Ltd","email":"office@fleetdesk.example"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155309171920" Location: /api/owners/2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d { "id": "2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d", "name": "Fleet Desk Ltd", "email": "office@fleetdesk.example", "phone": null } ``` `PUT` replaces the whole row. A field the body leaves out is written `null`, unless it declares a literal `default`, which is filled in instead. Here the second `PUT` sends no `email`, so the owner loses it: ```sh curl -sS -X PUT http://localhost:8080/api/owners/2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"name":"Fleet Desk Limited"}' ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 ETag: "639273155309523090" { "id": "2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d", "name": "Fleet Desk Limited", "email": null, "phone": null } ``` To change only some fields, use `PATCH`, which merges the body into the stored row. Because a `PUT` can create or replace depending on what is stored, the caller needs both the `create` and the `update` rule, and both are checked against the row it would write. The id goes in the path only; a body that names `id` is refused on every route. ## 2. Retry a create without writing it twice A create whose response is lost, to a timeout or a dropped connection, leaves you not knowing whether the row exists. Send an `Idempotency-Key` header, a string you choose for this one operation, and retry with the same key: ```sh curl -sS -X POST http://localhost:8080/api/vehicles \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Idempotency-Key: import-2026-10-10-row-1" \ -H "Content-Type: application/json" \ -d '{"vin":"TMBJJ7NE8L0123456","plate":"BA-101AA","make":"Skoda","model":"Octavia","year":2020,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155309654570" Location: /api/vehicles/4903fb56-1628-476e-a4f1-ac6658c97724 { "id": "4903fb56-1628-476e-a4f1-ac6658c97724", "plate": "BA-101AA", "model": "Octavia" } ``` The retry writes nothing. It answers like the first request, with the same row: ```sh curl -sS -X POST http://localhost:8080/api/vehicles \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Idempotency-Key: import-2026-10-10-row-1" \ -H "Content-Type: application/json" \ -d '{"vin":"TMBJJ7NE8L0123456","plate":"BA-101AA","make":"Skoda","model":"Octavia","year":2020,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}' ``` ```http HTTP/1.1 201 Created Content-Type: application/json; charset=utf-8 ETag: "639273155309654570" Location: /api/vehicles/4903fb56-1628-476e-a4f1-ac6658c97724 { "id": "4903fb56-1628-476e-a4f1-ac6658c97724", "plate": "BA-101AA", "model": "Octavia" } ``` The same key with a different body is not a retry, and is refused rather than answered with the first row: ```sh curl -sS -X POST http://localhost:8080/api/vehicles \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Idempotency-Key: import-2026-10-10-row-1" \ -H "Content-Type: application/json" \ -d '{"vin":"TMBEG7NJ1N0234567","plate":"BA-202BB","make":"Skoda","model":"Fabia","year":2022,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}' ``` ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json { "type": "https://alvo.dev/errors/idempotency-conflict", "title": "Conflict", "status": 409, "detail": "This idempotency key was already used for a different request. Reusing one key for two requests would silently discard the second, so send a fresh key." } ``` Alvo stores only the ids of the rows the write touched under the key, scoped to the caller (their user and tenant), and on a replay reads the row again with the caller's current `get` rule; it never stores a response. A key is at most 255 UTF-8 bytes. A caller without a key cannot use one, because every anonymous caller shares one identity, and is refused with a 422. The header is honoured on every write: `POST`, `PUT`, `PATCH`, `DELETE` and the three batch verbs. A replayed `PUT` answers 200 without `Location`. ## 3. Update only what you have seen: `ETag` and `If-Match` On an entity with `"audit": true`, every single-row response also carries an `ETag`, its version. Send it back as `If-Match` and the write happens only if nobody has changed the row since you read it. Update the vehicle from step 2 with the `ETag` its create returned: ```sh curl -sS -X PATCH http://localhost:8080/api/vehicles/4903fb56-1628-476e-a4f1-ac6658c97724 \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "If-Match: \"639273155309654570\"" \ -H "Content-Type: application/json" \ -d '{"color":"green"}' ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 ETag: "639273155310170160" { "id": "4903fb56-1628-476e-a4f1-ac6658c97724", "color": "green", "created_at": "2026-10-11T11:38:50.965457+00:00", "created_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "make": "Skoda", "model": "Octavia", "owner_id": "2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d", "plate": "BA-101AA", "updated_at": "2026-10-11T11:38:51.017016+00:00", "updated_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "vin": "TMBJJ7NE8L0123456", "year": 2020 } ``` The row now has a new `ETag`. A client still holding the old one is refused, instead of silently overwriting the change it never saw: ```sh curl -sS -X PATCH http://localhost:8080/api/vehicles/4903fb56-1628-476e-a4f1-ac6658c97724 \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "If-Match: \"639273155309654570\"" \ -H "Content-Type: application/json" \ -d '{"color":"black"}' ``` ```http 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." } ``` On a 412, read the row again, apply your change to what is stored now, and send it with the new tag. `DELETE` takes `If-Match` the same way. A few rules, all refusals rather than silent passes: - An entity without `audit` has no version, so its rows carry no `ETag`, and `If-Match` with a tag is refused with a 412. `If-Match: *` asks only that the row exist and is accepted everywhere. - A weak tag (`W/"…"`), a list of tags, `If-None-Match` on a write, and any precondition on a create are refused with a 412. - On a read of one row, `If-None-Match` with the current tag answers 304 with no body. :::caution[Known issue in this build] A row with a rollup field changes when one of its child rows is written, but its `ETag` does not: the recompute does not advance the parent's version. An `If-Match` taken before the child write still succeeds, and `If-None-Match` still answers 304 over the old total. Read such a row again after writing its children before you condition a write on its tag ([#351](https://github.com/Burgyn/MMLib.Alvo/issues/351)). ::: ## 4. Write many rows in one transaction `/api//batch` takes `{"rows": [ … ]}` with three verbs: `POST` creates, `PATCH` updates (each row carries its `id`) and `DELETE` removes (each row is a bare id). Create two vehicles: ```sh curl -sS -X POST http://localhost:8080/api/vehicles/batch \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"rows":[{"vin":"WVWZZZAUZKW345678","plate":"KE-303CC","make":"Volkswagen","model":"Golf","year":2019,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"},{"vin":"WVWZZZ3CZPE456789","plate":"KE-404DD","make":"Volkswagen","model":"Passat","year":2023,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}]}' ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "affected": 2 } ``` Update both, naming them by the ids the create returned: ```sh curl -sS -X PATCH http://localhost:8080/api/vehicles/batch \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"rows":[{"id":"1c9e6a9e-d24b-4bae-8a93-8fd5a610b8fa","color":"silver"},{"id":"4f9b6d5f-730d-42d2-8dc3-93460f169bcc","color":"silver"}]}' ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [ { "id": "1c9e6a9e-d24b-4bae-8a93-8fd5a610b8fa", "color": "silver", "created_at": "2026-10-11T11:38:51.028367+00:00", "created_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "make": "Volkswagen", "model": "Golf", "owner_id": "2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d", "plate": "KE-303CC", "updated_at": "2026-10-11T11:38:51.039132+00:00", "updated_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "vin": "WVWZZZAUZKW345678", "year": 2019 }, { "id": "4f9b6d5f-730d-42d2-8dc3-93460f169bcc", "color": "silver", "created_at": "2026-10-11T11:38:51.028367+00:00", "created_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "make": "Volkswagen", "model": "Passat", "owner_id": "2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d", "plate": "KE-404DD", "updated_at": "2026-10-11T11:38:51.039132+00:00", "updated_by": "6c51972a-4b35-8468-8cdb-d8f54a226a0a", "vin": "WVWZZZ3CZPE456789", "year": 2023 } ], "affected": 2 } ``` And delete them. A batch delete answers 200 with `affected`, so a five-row delete can be told from a refusal: ```sh curl -sS -X DELETE http://localhost:8080/api/vehicles/batch \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"rows":["1c9e6a9e-d24b-4bae-8a93-8fd5a610b8fa","4f9b6d5f-730d-42d2-8dc3-93460f169bcc"]}' ``` ```http HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 { "items": [], "affected": 2 } ``` Every row is checked before any row is written, so one bad row writes nothing, and the refusal lists every bad row with a pointer into `rows`. Here the second row has no `year`: ```sh curl -sS -X POST http://localhost:8080/api/vehicles/batch \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"rows":[{"vin":"VF1RJA00X67567890","plate":"ZA-505EE","make":"Renault","model":"Clio","year":2017,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"},{"vin":"VF1RJA00X67567891","plate":"ZA-606FF","make":"Renault","model":"Captur","owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}]}' ``` ```http HTTP/1.1 422 Unprocessable Entity Content-Type: application/problem+json { "type": "https://alvo.dev/errors/validation", "title": "Unprocessable Entity", "status": 422, "detail": "A field the entity declares required is missing or null.", "violations": [ { "pointer": "/rows/1/year", "code": "required", "message": "A field the entity declares required is missing or null.", "fixSuggestion": "Supply a value for it. A create must carry every required field; a partial update may omit any field it is not changing, but may not null a required one." } ] } ``` A row refused by a rule makes the whole batch a 403 whose `violations` name each refused row. A batch holds up to 1000 rows ([Limits and budgets](https://alvo.burgyn.online/reference/limits/)), may not name one row twice, and takes no `If-Match`: one version cannot condition many rows. A batch update stamps each row's `updated_at` and `updated_by` as a single update does, so each row gets a new `ETag` and a tag taken before the batch no longer matches. Every row written still emits its own event, so an after-hook or webhook runs once per row ([#193](https://github.com/Burgyn/MMLib.Alvo/issues/193)). ## 5. Send JSON, and say so Every request with a body must declare `Content-Type: application/json`. Any other media type, or none at all, is refused before the body is read, and the answer names the type it accepts: ```sh curl -sS -X POST http://localhost:8080/api/owners \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: text/plain" \ -d '{"name":"Fleet Desk Ltd"}' ``` ```http HTTP/1.1 415 Unsupported Media Type Content-Type: application/problem+json Accept-Post: application/json { "type": "https://alvo.dev/errors/unsupported-media-type", "title": "Unsupported Media Type", "status": 415, "detail": "This endpoint reads a JSON request body. Send it with 'Content-Type: application/json'." } ``` Also accepted: any `application/*+json` type, such as `application/merge-patch+json`, and a `charset` parameter, which is ignored (bodies are read as UTF-8). The requirement has no switch. It is what keeps a cross-site form from writing: a browser sends a form as `text/plain` or `application/x-www-form-urlencoded` without asking the server first, and it never sends `application/json` that way. ## How it works Each write runs in one database transaction. Alvo checks the body against the entity's fields; then, over the locked row, it runs the [before-hooks](https://alvo.burgyn.online/guides/before-hooks/), checks the `create` or `update` rule against the row it is about to store and compares `If-Match` with the row's version; it writes the row and records its event before it commits. A write answers with the row exactly as a `GET` by the same caller would show it; when the caller may not read that row, the answer carries only its `id`. [Security model](https://alvo.burgyn.online/concepts/security-model/) explains the order of the checks. ## Options and variations | You want to | Send | Notes | |---|---|---| | Create with a generated id | `POST /api/` | 201 and `Location`. | | Create or replace under your id | `PUT /api//` | 201 on create, 200 on replace; needs `create` and `update`. | | Change some fields | `PATCH /api//` | Merges the fields you send into the row. | | Delete | `DELETE /api//` | 204 with no body. | | Retry safely | `Idempotency-Key: ` | On every write: `POST`, `PUT`, `PATCH`, `DELETE` and the batch verbs. | | Not overwrite a change | `If-Match: ` | On `PATCH`, `PUT` and `DELETE` of an audited entity. | | Write many rows | `POST`, `PATCH` or `DELETE /api//batch` | All or nothing, up to 1000 rows. | Headers, statuses and route shapes are summarised in [Data API conventions](https://alvo.burgyn.online/data-api/conventions/). :::caution[Not in this build] Stored idempotency keys never expire yet ([#115](https://github.com/Burgyn/MMLib.Alvo/issues/115)). This is not listed on [Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/), which covers descriptor blocks only. ::: ## What can go wrong A value another row already holds on a `unique` field is a conflict only the database can detect: ```sh curl -sS -X POST http://localhost:8080/api/vehicles \ -H "X-Alvo-Api-Key: demo.$ALVO_DEMO_KEY_SECRET" \ -H "Content-Type: application/json" \ -d '{"vin":"TMBJJ7NE8L0999999","plate":"BA-101AA","make":"Skoda","model":"Superb","year":2024,"owner_id":"2c4e6a80-1b3d-4f5a-8c7e-9d0f1a2b3c4d"}' ``` ```http HTTP/1.1 409 Conflict Content-Type: application/problem+json { "type": "https://alvo.dev/errors/conflict", "title": "Conflict", "status": 409, "detail": "This field is declared unique and another record already holds the value sent for it.", "violations": [ { "pointer": "/plate", "code": "unique", "message": "This field is declared unique and another record already holds the value sent for it.", "fixSuggestion": "Send a value no other record holds, or change the record that holds it." } ] } ``` | Status | Problem type | When | Fix | Returned by | |---|---|---|---|---| | 412 | [`precondition-failed`](https://alvo.burgyn.online/reference/problem-types/#precondition-failed) | `If-Match` names a version the row no longer has, or a precondition Alvo cannot evaluate. | Read the row again and retry with its `ETag`. | every host | | 409 | [`idempotency-conflict`](https://alvo.burgyn.online/reference/problem-types/#idempotency-conflict) | The `Idempotency-Key` was already used for a different request. | Send a new key. | every host | | 409 | [`conflict`](https://alvo.burgyn.online/reference/problem-types/#conflict) | A `unique` value another row holds, or a delete a `restrict` reference blocks. | Send a different value, or remove what references the row. | every host | | 415 | [`unsupported-media-type`](https://alvo.burgyn.online/reference/problem-types/#unsupported-media-type) | The body is not declared as JSON, or no `Content-Type` is sent. | Send `Content-Type: application/json`. | every host | | 422 | [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation) | A field is missing, too long, of the wrong type, unknown or read-only; a batch is empty or has too many rows. | Fix each field a violation points at. | every host | | 422 | [`malformed-query`](https://alvo.burgyn.online/reference/problem-types/#malformed-query) | An `Idempotency-Key` is longer than 255 bytes, or sent without an API key. | Shorten it, or authenticate. | every host | | 403 | [`forbidden`](https://alvo.burgyn.online/reference/problem-types/#forbidden) | A rule or a before-hook refused the write; in a batch, `violations` name each refused row. | Check the `create` or `update` rule and the hooks. | every host | | 404 | [`not-found`](https://alvo.burgyn.online/reference/problem-types/#not-found) | The row of a `PATCH` or `DELETE` does not exist, or your rule excludes it. | Check the id and the rule. | every host | | 413, 408, 400 | [`unreadable-request`](https://alvo.burgyn.online/reference/problem-types/#unreadable-request) | The web server refused the body before Alvo read it: too large, too slow, or broken framing. | Send a smaller body, or split a batch. | standalone; embedded only with `AddAlvoProblemDetails()` | ## Reference - [Data API conventions](https://alvo.burgyn.online/data-api/conventions/) and [Limits and budgets](https://alvo.burgyn.online/reference/limits/). - Descriptor keys: [`audit`](https://alvo.burgyn.online/reference/descriptor/entities/#entities.audit), which gives rows a version. - Problem types: [`precondition-failed`](https://alvo.burgyn.online/reference/problem-types/#precondition-failed), [`idempotency-conflict`](https://alvo.burgyn.online/reference/problem-types/#idempotency-conflict), [`conflict`](https://alvo.burgyn.online/reference/problem-types/#conflict), [`unsupported-media-type`](https://alvo.burgyn.online/reference/problem-types/#unsupported-media-type), [`validation`](https://alvo.burgyn.online/reference/problem-types/#validation), [`unreadable-request`](https://alvo.burgyn.online/reference/problem-types/#unreadable-request). - Design notes: [optimistic concurrency](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#optimistic-concurrency-a-strong-etag-over-the-row-version), [the batch](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#the-batch-one-path-three-verbs-one-transaction-106), [`Idempotency-Key`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#idempotency-key-what-is-stored-and-where-it-is-honoured), [create-or-replace](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#create-or-replace-put-prefixentityid-105) and [the JSON `Content-Type` requirement](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md#requiring-a-json-content-type-191). ## Next **[Handle errors](https://alvo.burgyn.online/guides/handle-errors/)**: read a problem document and branch on its type. --- # Examples Source: https://alvo.burgyn.online/examples/ Each example lives under `examples/` in the repository and is validated against the descriptor schema on every build. The ones that apply also ship inside the image, under `/alvo/examples/`, so the [Quick start](https://alvo.burgyn.online/start-here/quick-start/)'s compose file serves any of them with `ALVO_DESCRIPTOR` and no clone. Each summary comes from [`examples/README.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/README.md), which also lists the keys the schema declares but this build refuses at apply. ## bike-workshop The admin dashboard's demo backend (see `bike-workshop/README.md`): a bicycle repair and rental workshop over eight entities, exercising every field type, declared `formats`, literal `default`s, all three `onDelete`s, `computed` fields (one reading a rollup), `count`/`sum` rollups, `hidden`/`readOnly` as CEL, before-hooks (`reject` and `mutate`), `email` and `webhook` after-hooks, `access` levels and role-differentiated rules. `scripts/demo-admin` starts the host over it and seeds realistic data from `bike-workshop/seed/` through the public API. **Applies:** yes **Entities:** `technicians`, `customers`, `bikes`, `parts`, `service_orders`, `order_lines`, `rental_fleet`, `rentals` **Roles a key needs:** `manager`, `reception`, `technician` **Tenancy:** single-tenant **Descriptor:** [`examples/bike-workshop/bike-workshop.alvo.json`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/bike-workshop/bike-workshop.alvo.json) In the image at `/alvo/examples/bike-workshop/bike-workshop.alvo.json`. Serve it with the quick start's compose file, from the directory that holds it; the `down` deletes the database of the descriptor it served before: ```sh docker compose -f docker-compose.quickstart.yml down --volumes ALVO_DESCRIPTOR=/alvo/examples/bike-workshop/bike-workshop.alvo.json docker compose -f docker-compose.quickstart.yml up --wait ``` The quick start's `demo` key holds only the built-in roles `admin` and `authenticated`, so it authenticates here; for keys with this example's own roles, use an override as [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/) shows. ## field-service The runnable complex demo (see `field-service/README.md`) and the fixture the `test/teapie-field-service` end-to-end suite drives: a multi-tenant field-service dispatch backend over three entities — `regions` (`tenancy: global`, shared reference data) and the tenant-scoped `customers` and `work_orders`. It exercises `tenancy`, `audit` on one entity and its absence on another (so `If-Match` is honoured on one and refused with 412 on the other), `hidden` and `readOnly` fields, role-differentiated and row-level rules, an operation with no rule at all, `ref` with `onDelete: restrict`, one field of each type, and built-in and declared `formats`. Its own stack is `docker-compose.field-service.yml`, with one dev key per role and tenant. **Applies:** yes **Entities:** `regions`, `customers`, `work_orders` **Roles a key needs:** `dispatcher`, `technician` **Tenancy:** multi-tenant (`tenancy.enabled: true`) **Descriptor:** [`examples/field-service/field-service.alvo.json`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/field-service/field-service.alvo.json) In the image at `/alvo/examples/field-service/field-service.alvo.json`. Serve it with the quick start's compose file, from the directory that holds it; the `down` deletes the database of the descriptor it served before: ```sh docker compose -f docker-compose.quickstart.yml down --volumes ALVO_DESCRIPTOR=/alvo/examples/field-service/field-service.alvo.json docker compose -f docker-compose.quickstart.yml up --wait ``` The quick start's `demo` key belongs to no tenant, so there only the entities marked `tenancy: global` answer; the header of `docker-compose.quickstart.yml` says how to give the key a tenant. The example's own stack, `docker-compose.field-service.yml`, has one dev key per role and tenant and runs from a clone of the repository. Generate a secret for each key and start it, as its [README](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/field-service/README.md) describes: ```sh export ALVO_FS_DISPATCHER_NORTH_SECRET="$(openssl rand -hex 16)" export ALVO_FS_TECH_NORTH_SECRET="$(openssl rand -hex 16)" export ALVO_FS_SPARE_NORTH_SECRET="$(openssl rand -hex 16)" export ALVO_FS_DISPATCHER_SOUTH_SECRET="$(openssl rand -hex 16)" export ALVO_FS_ADMIN_SECRET="$(openssl rand -hex 16)" docker compose --env-file examples/field-service/demo-identities.env -f docker-compose.field-service.yml up --build --wait ``` ## help-desk A support desk's `tickets`: an `enum` priority and status with literal `default`s, a `decimal` estimate and a `computed` field over it, `audit`, role-differentiated rules, and two before-hooks (a `mutate` that trims the title with `trim`, a `reject` for a high-priority ticket without a body). It declares the roles `admin` and `agent`. It is the end state of the docs site's tutorial (Tutorial: your first backend) and the source of the README's "See it" section. **Applies:** yes **Entities:** `tickets` **Roles a key needs:** `admin`, `agent` **Tenancy:** single-tenant **Descriptor:** [`examples/help-desk/help-desk.alvo.json`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/help-desk/help-desk.alvo.json) In the image at `/alvo/examples/help-desk/help-desk.alvo.json`. Serve it with the quick start's compose file, from the directory that holds it; the `down` deletes the database of the descriptor it served before: ```sh docker compose -f docker-compose.quickstart.yml down --volumes ALVO_DESCRIPTOR=/alvo/examples/help-desk/help-desk.alvo.json docker compose -f docker-compose.quickstart.yml up --wait ``` The quick start's `demo` key holds only the built-in roles `admin` and `authenticated`, so it authenticates here; for keys with this example's own roles, use an override as [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/) shows. ## simple-tasks The smallest real backend, and the one to start from: two owned entities (`projects`, `tasks`), ownership rules, an `enum`, `audit`, one composite index. It deliberately leaves out rollups, defaults and hooks, because the smallest starting point is the point; the other examples show those. **Applies:** yes **Entities:** `projects`, `tasks` **Roles a key needs:** none declared; `authenticated` is enough **Tenancy:** single-tenant **Descriptor:** [`examples/simple-tasks/tasks.alvo.json`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/simple-tasks/tasks.alvo.json) In the image at `/alvo/examples/simple-tasks/tasks.alvo.json`. Serve it with the quick start's compose file, from the directory that holds it; the `down` deletes the database of the descriptor it served before: ```sh docker compose -f docker-compose.quickstart.yml down --volumes ALVO_DESCRIPTOR=/alvo/examples/simple-tasks/tasks.alvo.json docker compose -f docker-compose.quickstart.yml up --wait ``` The quick start's `demo` key holds only the built-in roles `admin` and `authenticated`, so it authenticates here; for keys with this example's own roles, use an override as [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/) shows. ## vehicle-registry The demo the root `docker-compose.yml` serves: owners, their vehicles, and periodic roadworthiness inspections. Exercises two `ref` chains (`vehicles.owner_id` → `owners`, `inspections.vehicle_id` → `vehicles`, the latter `onDelete: cascade`), a composite index on each of `vehicles` and `inspections`, `audit` on both `owners` and `vehicles`, and a `renamedFrom` on `vehicles.plate` (was `license_plate`). **Applies:** yes **Entities:** `owners`, `vehicles`, `inspections` **Roles a key needs:** `inspector`, `admin` **Tenancy:** single-tenant **Descriptor:** [`examples/vehicle-registry/vehicles.alvo.json`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/vehicle-registry/vehicles.alvo.json) In the image at `/alvo/examples/vehicle-registry/vehicles.alvo.json`. Serve it with the quick start's compose file, from the directory that holds it; the `down` deletes the database of the descriptor it served before: ```sh docker compose -f docker-compose.quickstart.yml down --volumes ALVO_DESCRIPTOR=/alvo/examples/vehicle-registry/vehicles.alvo.json docker compose -f docker-compose.quickstart.yml up --wait ``` The quick start's `demo` key holds only the built-in roles `admin` and `authenticated`, so it authenticates here; for keys with this example's own roles, use an override as [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/) shows. ## complex-crm A format showcase, not a runnable backend (see `complex-crm/NOT-RUNNABLE.md`): a CRM written in the v1 format, exercising most of the surface including keys this build refuses, which is exactly why applying it fails. It is the schema corpus's one full-surface fixture, covering multi-tenancy (`tenancy.enabled` + a `global` lookup table), dynamic-entities governance (`dynamicEntities.defaultRules` + quotas), `rollup.via`, a `computed` field reading a `rollup` (`gross_total`), a declarative `formats` entry (`sk-ico`) referenced by a field, field-level per-role masking (`hidden` as CEL), tagged `{"$cel": …}` values, `renamedFrom`, `templates`, outbound `webhooks`, a `batch`-delivery automation rule, a scheduled rule delegating to a `function`, and `x-` keys. It is a real bundle: `crm.alvo.json` alongside `templates/invoice-issued.html` (referenced via `bodyFile`) and `functions/remind-stale-deals.csx` (referenced via `script`). **Applies:** no — [why](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/complex-crm/NOT-RUNNABLE.md) **Entities:** `countries`, `companies`, `contacts`, `deals`, `invoices`, `invoice_items` **Roles a key needs:** `sales`, `manager`, `finance` **Tenancy:** multi-tenant (`tenancy.enabled: true`) **Descriptor:** [`examples/complex-crm/crm.alvo.json`](https://github.com/Burgyn/MMLib.Alvo/blob/main/examples/complex-crm/crm.alvo.json) --- # The project descriptor Source: https://alvo.burgyn.online/concepts/descriptor/ An Alvo backend is defined by one JSON document, the **project descriptor**. It says what the backend *is*: its entities and fields, who may read and write each one, what happens as a row is written, which values are derived, and who may manage the project. Alvo turns it into tables, a REST API with its own OpenAPI document, compiled access rules and hooks. There is no second place where any of that is configured. This is the whole help-desk backend the [tutorial](https://alvo.burgyn.online/start-here/tutorial/) builds: ```json { "$schema": "https://alvo.dev/schema/v1/project.json", "apiVersion": "alvo.dev/v1", "name": "help-desk", "auth": { "providers": ["local"], "roles": ["admin", "agent"] }, "entities": { "tickets": { "audit": true, "fields": { "title": { "type": "string", "required": true, "maxLength": 120 }, "body": { "type": "text" }, "priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" }, "status": { "type": "enum", "values": ["open", "closed"], "default": "open" }, "estimate_hours": { "type": "decimal", "precision": 5, "scale": 1 }, "hourly_rate": { "type": "decimal", "precision": 6, "scale": 2 }, "estimate_cost": { "type": "decimal", "precision": 12, "scale": 2, "computed": "estimate_hours * hourly_rate" } }, "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", "delete": "'admin' in @user.roles" }, "hooks": { "beforeCreate": [ { "action": { "mutate": { "title": { "$cel": "trim(new.title)" } } } }, { "condition": "new.priority == 'high' && !has(new.body)", "action": { "reject": "A high-priority ticket needs a body." } } ] } } } } ``` ## What belongs in it, and what does not The descriptor defines the backend. **Infrastructure stays out**: connection strings, API keys, the administrator's credentials, the AI connection, the startup mode and the path the host is served under are configuration of the deployment ([Configuration keys](https://alvo.burgyn.online/reference/configuration/)). The line is deliberate: a descriptor can live in Git and be reviewed like code without carrying a secret, and the same descriptor runs unchanged against a laptop's SQLite file and a production PostgreSQL. Access rules sit on the other side of that line: who may manage the project is the descriptor's `access` block, so it is versioned and reviewed with everything else. ## One descriptor, two homes The format is one; where the current version lives has two answers. - **A file.** The standalone image reads `/alvo/descriptor.json`; an embedded host passes a path to `FromDescriptor`. The file can live in your repository, and changing it plus a restart is a deploy. - **Records in the database.** Every apply, on boot or through the [Management API](https://alvo.burgyn.online/reference/management-api/) or the dashboard, appends a revision to the project's history in the database: the descriptor, who applied it, when and why. That history is append-only, and it is what the dashboard reads and rolls back. The two meet at every boot. The host compares the file with the database: a descriptor the history already holds at an older revision is not served, so a stale file can never quietly undo a change made through the API. The dashboard's *Import / export* moves a descriptor between the two homes byte for byte. Which home is authoritative is your choice; [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/) shows both loops. ## The top-level blocks Only `apiVersion`, `name` and `entities` are required. Each block has its own reference page, generated from the schema: | Block | What it defines | In this build | |---|---|---| | [`entities`](https://alvo.burgyn.online/reference/descriptor/entities/) | entities with their [fields](https://alvo.burgyn.online/reference/descriptor/entities-fields/), [rules](https://alvo.burgyn.online/reference/descriptor/entities-rules/), [hooks](https://alvo.burgyn.online/reference/descriptor/entities-hooks/), [computed fields and rollups](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/) and [indexes](https://alvo.burgyn.online/reference/descriptor/entities-indexes/) | honoured, except the parts listed as refused | | [`auth`](https://alvo.burgyn.online/reference/descriptor/auth/) | the application's roles, and the sign-in providers | honoured; sign-in providers other than `local` are not run | | [`tenancy`](https://alvo.burgyn.online/reference/descriptor/tenancy/) | whether the backend is multi-tenant | honoured | | [`access`](https://alvo.burgyn.online/reference/descriptor/access/) | which roles reach the `viewer`, `developer` and `admin` management levels | honoured | | [`formats`](https://alvo.burgyn.online/reference/descriptor/formats/) | named validation formats a field can reference | honoured | | [`templates`](https://alvo.burgyn.online/reference/descriptor/templates/), [`webhooks`](https://alvo.burgyn.online/reference/descriptor/webhooks/) | message templates and webhook endpoints an after-hook uses | run when an after-hook uses them; the rest is not | | [`automation`](https://alvo.burgyn.online/reference/descriptor/automation/), [`functions`](https://alvo.burgyn.online/reference/descriptor/functions/), [`dynamicEntities`](https://alvo.burgyn.online/reference/descriptor/dynamic-entities/) | event rules, custom functions, runtime entities | parsed, not run | | [`branding`](https://alvo.burgyn.online/reference/descriptor/branding/) | the project's display name and logo | parsed, nothing renders it yet | `$schema`, `description` and `revision` are metadata, and keys starting with `x-` are carried through apply and export untouched, for your own tooling. [Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/) is the authority on the last column. ## How a descriptor is checked A descriptor is checked in layers, and every finding names the place with a JSON pointer and says how to fix it. All of it happens when the descriptor is applied, never when a request arrives: a rule naming a column that does not exist fails the apply, not the first caller. 1. **The JSON Schema.** The shape: required keys, types, names, the facets each field type must carry. The schema closes every object (`additionalProperties: false`, with only `x-` extension keys admitted), so a misspelled key is an error, not a silently ignored one. 2. **The semantic checks.** What the schema cannot express: references to entities and fields that exist, reserved and framework-managed names, roles a rule names that `auth.roles` does not declare. Every rule, hook and computed expression is compiled in its [CEL profile](https://alvo.burgyn.online/concepts/cel/) against the entity it belongs to. 3. **The apply.** Features this build accepts in the schema but cannot honour are **refused** with a reason, rather than accepted and ignored. Blocks it parses and does not run are **warned**. Then the migration is planned against the current schema, and a plan that would discard data is refused unless the apply allows it explicitly. The same checks answer a dry run (`?dryRun=true` on the Management API), the dashboard's live check on each expression, and the schema assistant's proposals. Nothing applies half a descriptor. ## Revisions Every accepted apply appends a **revision**: the descriptor, its number, the author and the reason. A change through the Management API names the revision it was written against in `If-Match`; if someone applied first, the change is refused rather than written over theirs. A rollback applies an earlier revision's descriptor as a new revision; nothing is erased. [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/) walks through it, including renames that keep their data and drops that need your permission. ## Format version and editor support `apiVersion` is `alvo.dev/v1`. The schema's own rule for it: within v1 the format only grows, and a breaking change becomes `alvo.dev/v2`. The schema has a description on every key, and this site serves it at `https://alvo.burgyn.online/schema/v1/project.json`. The `$schema` URL a descriptor carries, `https://alvo.dev/schema/v1/project.json`, does not resolve yet, so map your editor to the served copy: [For coding agents](https://alvo.burgyn.online/start-here/coding-agents/#2-give-the-editor-the-schema) shows the VS Code setting. ## Put it to work - [Tutorial: your first backend](https://alvo.burgyn.online/start-here/tutorial/): write a descriptor step by step. - [Entities and fields](https://alvo.burgyn.online/guides/entities-and-fields/): the core of every descriptor. - [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/): change a running backend safely. - [Descriptor reference](https://alvo.burgyn.online/reference/descriptor/): every key, generated from the schema. --- # CEL in Alvo Source: https://alvo.burgyn.online/concepts/cel/ Every condition and every derived value in a descriptor is written in [CEL](https://cel.dev), the Common Expression Language: an access rule, a hook's condition, a `mutate` value, a computed field, a management access level. One grammar, one parser and one type checker serve all of them. ## Why CEL CEL was designed for exactly this job: small expressions that decide something, evaluated where a mistake must not cost more than a wrong answer. It has no loops, no recursion, no I/O and no way to define a function, so an expression always finishes, and its cost is bounded by its length, not by the data or the caller; a function the host registers in C# is the one exception. That is what lets Alvo run a rule inside the database query and a hook inside the write's transaction. Alvo adopts the CEL specification rather than inventing a language, so a coding agent recognizes it from its training data. Where Alvo differs from the specification, it says so: see [Deviations](#deviations-from-the-cel-specification) below. Transformations of a payload are a different job, for JSONata, which is not in this build. ## The five profiles What an expression may contain depends on where it stands. Each place in the descriptor compiles its expressions in one **profile**, and a construct a profile does not list is refused there. The default is to refuse: a construct missing from the table compiles nowhere, not everywhere. | Construct | Rule | Computed | Condition | Mutate | Access | |---|---|---|---|---|---| | Literal | ✓ | ✓ | ✓ | ✓ | ✓ | | Field of the current row (`owner_id`) | ✓ | ✓ | ✓ | ✓ | ✗ | | Field of the row before or after the write (`old.status`, `new.status`) | ✗ | ✗ | ✓ | ✓ | ✗ | | `@user` | ✓ | ✗ | ✓ | ✗ | ✓ | | `@tenant` | ✓ | ✗ | ✓ | ✗ | ✗ | | `&&`, `\|\|`, `!` | ✓ | ✓ | ✓ | ✗ | ✓ | | Comparison (`==`, `!=`, `<`, `<=`, `>`, `>=`) | ✓ | ✓ | ✓ | ✗ | ✓ | | `in` (role membership) | ✓ | ✗ | ✓ | ✗ | ✓ | | `has(field)` | ✓ | ✓ | ✓ | ✗ | ✗ | | Arithmetic (`+`, `-`, `*`, `/`, unary `-`) | ✗ | ✓ | ✓ | ✓ | ✗ | | Joining two strings with `+` | ✗ | ✓ | ✗ | ✓ | ✗ | | Conditional (`a ? b : c`) | ✗ | ✓ | ✗ | ✗ | ✗ | | `changed(field)` | ✗ | ✗ | ✓ | ✗ | ✗ | | `now()` | ✗ | ✗ | ✗ | ✓ | ✗ | | A [catalogued function](https://alvo.burgyn.online/reference/cel-functions/), built in or registered by the host | ✗ | ✗ | ✓ | ✓ | ✗ | Each function narrows that last row further with its own list of profiles. Where each profile applies, and what it must produce: - **Rule**: an entity's `rules` and a field's `hidden` and `readOnly` flags. A boolean over the current row, `@user` and `@tenant`. It is a filter, not a calculation, so there is no arithmetic, and there is no row "before" an authorization check, so no `old.` or `new.`. - **Computed**: a `computed` field. A value (a number, text, an instant or an id, never a bare boolean) the database computes from the same row, with no caller: no `@user`, no `@tenant`. The only profile with the conditional, which is how a computed field picks between two values. - **Condition**: a hook's `condition`. A boolean that sees the row before and after the write, and the only profile with `changed(field)`. - **Mutate**: the values of a before-hook's `mutate`. A value a field can hold, booleans included, computed from the row, the functions and arithmetic; no comparisons yet. - **Access**: the `access` block's three management levels. A boolean over `@user` alone: there is no row, and no tenant, because the levels apply to the whole project. The context is a closed set: `@user.id`, `@user.roles` and `@tenant.id`. Anything else after `@` is refused with a fix, for example `@user.role` (test membership with `'admin' in @user.roles` instead). ## Compiled when the descriptor is applied Every expression is compiled when the descriptor is applied, against the entity it belongs to. An unknown field, a type mismatch, a construct the profile refuses, or a role literal `auth.roles` does not declare (`'amdin' in @user.roles` would otherwise never match, and negated, match everyone) fails the apply with a pointer to the place and a fix. Nothing is parsed when a request arrives. ## How a Rule becomes SQL A rule is not checked against rows after they are loaded. It is rendered into the `WHERE` clause of the one statement that reads them, with every value from the caller bound as a parameter, never written into the SQL text. Postgres's own row-level security model gives the shape: | Operation | Row filter (`USING`) | Check on the row being written (`WITH CHECK`) | |---|---|---| | `list`, `get`, `delete` | ✓ | — | | `create` | — | ✓ | | `update` | ✓ | ✓, the same compiled rule | An operation with no rule is refused outright; a missing rule never means "no restriction". A tenant-scoped entity adds `tenant_id == @tenant.id` to every operation, compiled the same way. This is what the PostgreSQL renderer produces for a rule that admits public rows and the caller's own, captured by the repository's snapshot test: ```txt Rule: is_public || owner_id == @user.id, Sql: (COALESCE("is_public", FALSE) OR COALESCE("owner_id" = @alvo_u0, FALSE)), Parameters: [ alvo_u0:Guid ] ``` A role test names no column, so it is decided from the caller's roles when the statement is built. For the snapshot's caller, who holds `admin`, it leaves a constant: ```txt Rule: 'admin' in @user.roles, Sql: TRUE ``` On a `create`, there is no stored row to filter, so the same rule is evaluated in memory over the row about to be written. A test runs both evaluators over generated expressions to prove they always agree. ## Two-valued: a comparison with null is false SQL's comparisons have a third answer, "unknown", whenever one side is `null`. Alvo's do not: **a comparison where either side is `null` is `false`**, in SQL and in memory alike, and `!` applies to that answer. That is what each `COALESCE(…, FALSE)` above does. It has one consequence worth remembering. `!(owner_id == @user.id)` over a row whose `owner_id` is `null` is `!(false)`, so `true`: a rule meant as "everyone but the owner" also admits rows with no owner. Test presence with `has(owner_id)` when it matters. For the same reason `owner_id == null` is refused outright, with `has()` as the fix, since it would always be false. ## Fail closed When an expression cannot give a trustworthy answer, the answer is "no": - **A missing caller value refuses the call.** If a rule reads `@user.id` and the caller has no identity, or reads `@tenant.id` and the caller has no tenant, the operation is refused before any SQL is built, instead of comparing against an empty value. For a `hidden` or `readOnly` flag, the field simply stays hidden or read-only. - **A failing function aborts the write.** A function that fails while a hook is evaluated (a host function that throws, a built-in given a value it refuses) stops the whole evaluation and nothing is written. CEL's rule that `f(x) && false` may still answer `false` is not modelled; the write fails instead. - **Arithmetic that overflows or divides by zero** fails in a hook the same way, rather than storing a wrong value. - **A construct nobody listed** compiles in no profile. ## Deviations from the CEL specification Each deviation is deliberate and recorded, so a reader can tell a decision from an oversight. The complete numbered list with the reason for each is in [`docs/architecture/cel.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/cel.md#deliberate-deviations-from-cel). **Additions**, which conformant CEL does not have: - the `@user` and `@tenant` context, written with `@`; - `changed(field)`, and the `old.` and `new.` prefixes for the row before and after a write; - `now()`, a call rather than a variable. It returns the instant the write is stamped with, not a fresh clock read. **Narrowings**, constructs CEL has that Alvo refuses: - no list or map literals (`[1, 2]`, `{…}`) and no comprehension macros (`all`, `exists`, `map`, `filter`); - field access is flat: a bare field name, or one level of `old.` or `new.`; `has()` takes exactly one such name; - comparing with a `null` literal is refused in favour of `has()`; - `<`, `<=`, `>` and `>=` on text only in a computed field, where the database compares by its own collation; - no `%`; numbers are plain decimal digits, with no hexadecimal, exponent or unsigned suffix; - string escapes are limited to `\n`, `\t`, `\r`, `\\`, `\'` and `\"`. **Function calls**: catalogued functions are called globally, `trim(new.title)` rather than `new.title.trim()`; a `null` argument makes the result `null`; decimals stay decimals (Alvo has no `double`): `math.round` returns the type it is given, and `math.round(x, digits)` takes a decimal and a number of digits from 0 to 28. The [CEL functions](https://alvo.burgyn.online/reference/cel-functions/) reference lists every function with its profiles. ## Put it to work - [Access rules](https://alvo.burgyn.online/guides/access-rules/): the Rule profile, and what a caller sees when a rule excludes them. - [Validate and transform writes (before-hooks)](https://alvo.burgyn.online/guides/before-hooks/): the Condition and Mutate profiles. - [Computed fields and rollups](https://alvo.burgyn.online/guides/computed-and-rollups/): the Computed profile. - [Custom CEL functions](https://alvo.burgyn.online/guides/custom-cel-functions/): add a function from your own C# host. - [CEL functions](https://alvo.burgyn.online/reference/cel-functions/): the catalog. --- # Security model Source: https://alvo.burgyn.online/concepts/security-model/ Alvo's security rests on one premise: **the only way to the data is through Alvo**. Every request, from the generated API, the dashboard or your own C# endpoints, reaches the database through one data port that applies the descriptor's rules inside the query itself. This page states what that buys, what it does not, and who can change what. ## In short What Alvo guarantees, for every access that goes through it: - **Nothing is reachable by default.** An operation without a rule is refused, and the Management API admits nobody but the bootstrap administrator until the descriptor grants a level. - **Rules are enforced inside the data layer.** A read's rule is compiled into the `WHERE` clause of the one statement that reads, with every caller value bound as a parameter. A write's check on the row being stored is evaluated in memory, inside the write's transaction. No filter or page size can loosen either, and an endpoint of your own that calls Alvo's data port is judged by the same rules for the caller it passes. - **A write either satisfies every check or does not happen.** Before-hooks run inside the write's transaction; a refusal or a failure rolls the whole write back, event included. - **Nothing the descriptor can express reaches the network inside a write's transaction**, by construction rather than by convention. The one exception is a function a host developer registers in C#, which is host code. - **Policy refusals disclose kinds, not data.** A refused read or write says what kind of refusal it is; a row a rule hides answers the same as a row that does not exist, except that a constraint conflict can reveal it (below). - **Every request is bounded**: page size, body size and depth, filter depth and width, batch rows. The values are in [Limits and budgets](https://alvo.burgyn.online/reference/limits/). Where the guarantees stop: - **Out-of-band access bypasses all of it.** A direct SQL connection, another service writing to the same database, or a restored dump is not judged by any rule and emits no event, so no after-hook runs and nothing records the change. Treat the database credentials as the keys to everything. - **Host code is trusted.** A C# function or endpoint a host developer adds runs with the host's power, not within the descriptor's grammar. Who the caller of a custom endpoint is, and any database access that does not go through Alvo's data port, is the host's responsibility. - **A constraint conflict (`409`) can reveal rows a rule hides**, because the database enforces a constraint over every row, not over the rows a caller may see: - a `PUT` create-or-replace on an id held by a row the caller's rule hides, or by another tenant's row, answers `409`, because the primary key is the id alone and cannot collide silently; the caller must already hold that UUID to ask; - a value a `unique` field already holds answers `409`, and that row may be one the caller cannot see: uniqueness is instance-wide on an entity that is not tenant-scoped, and tenant-wide on a scoped one; - a delete refused because a `ref` with `onDelete: restrict` still points at the row answers `409 conflict` with violation code `referenced`: it tells the caller that some record references it, which the caller may not be allowed to read. Do not make a guessable value, such as an e-mail address, `unique` if its existence is confidential. - **The schema's shape is public.** Which entities exist and their non-hidden fields are published by the routes and the OpenAPI document. Data and the names of hidden fields are not. ## Default-deny Every layer starts closed and opens only on an explicit declaration: - **An entity operation with no rule** is refused for everyone with `403 forbidden`. A missing rule never means "no restriction". - **An anonymous caller** is judged by the same rules as anyone else: it is served only where a rule admits it, for example one that tests the built-in `anon` role. A rule that reads `@user.id` refuses a caller with no identity rather than comparing against an empty one. - **A tenant-scoped entity** refuses a caller who has no tenant before any rule is consulted. - **The Management API** answers every route with `403` except to the bootstrap administrator, until the descriptor's `access` block maps roles to a level. - **The image ships no credential**: no API key and no administrator password. A host with none configured still starts, and refuses every operation no rule opens to the anonymous caller. - **An unknown construct in an expression** compiles nowhere, and a refused descriptor feature is refused at apply rather than accepted and ignored. ## Who can change what Three parties shape a running backend, and each can do only what its channel allows. | Party | Changes it through | Can | Cannot | |---|---|---|---| | **Descriptor author**: a developer, an agent, a dashboard user with the `developer` level | the descriptor: a file, the Management API, the dashboard | declare entities, rules, hooks, derived values and who may manage the project | express a loop, a network call or file access; reach past the [CEL profiles](https://alvo.burgyn.online/concepts/cel/); grant itself `admin`: a change to `access` needs the `admin` level | | **Host developer** (embedded mode) | C# in the host application | register CEL functions with `AddCelFunction`, add endpoints that call Alvo's data port, decide who the caller is for those endpoints, attach middleware to the generated routes | change what the descriptor's rules decide: an endpoint calling `IAlvoData` is judged by the same rules, for the caller it passes. Choosing that caller correctly, and any direct database access, is the host's responsibility | | **API caller** | an HTTP request with an API key | what the descriptor's rules allow for the key's user, roles and tenant, narrowed further by the key's scopes | see or change rows a rule excludes, write a framework-managed column, choose a tenant the key was not issued for | Two more sit outside the descriptor on purpose. **The operator** sets infrastructure: connection strings, API keys, the bootstrap administrator, the AI connection. Credentials never enter the descriptor, so a descriptor can be shared and reviewed without leaking one. **The bootstrap administrator** always holds the `admin` level, because a project that locked everyone out of its own `access` block would otherwise be unrecoverable. The two **escape hatches** are both the host developer's: a [custom CEL function](https://alvo.burgyn.online/guides/custom-cel-functions/) and a [custom endpoint](https://alvo.burgyn.online/guides/call-from-endpoints/). They let you outgrow the descriptor without leaving the runtime, and they are as trustworthy as the code you write in them. A host function is not bounded by CEL's grammar: it runs inside the write's transaction with no time budget, can loop or block, and Alvo's tenant filter does not reach inside it, so a function that reads stored data must filter by the tenant itself. That is why only a host developer can add one. An API key's scopes narrow what it reaches on the Data API only. On the Management API a key reaches whatever its **roles** reach, so narrow a key's management reach by narrowing its roles. ## Rules in the data layer Every write to the generated API passes the same gates, in this order: ```mermaid flowchart TD accTitle: The checks every write passes accDescr: A write request is refused with 401 when its API key cannot be used, with 403 out-of-scope when a presented key's scopes do not cover it, and with 403 forbidden when no rule allows the operation or the tenant or identity it needs is missing. An update or delete of a row the rule hides answers 404 not-found. Otherwise a transaction runs the before-hooks and then the rule's WITH CHECK on the row as it will be stored; a reject, a failure or a failed check rolls everything back. A passing write commits the row and its outbox event together, and the after-hooks run after the commit. req["Write request"] --> key{"API key usable?
(no key: anonymous)"} key -- no --> r401["401 unauthenticated"] key -- yes --> scope{"A presented key's scopes
cover entity and operation?"} scope -- no --> r403s["403 out-of-scope"] scope -- yes --> policy{"A rule for the operation,
and the tenant and identity it reads?"} policy -- no --> r403["403 forbidden"] policy -- yes --> visible{"Update or delete:
is the row visible under the rule?"} visible -- no --> r404["404 not-found"] visible -- "yes, or a create" --> tx["Transaction: before-hooks,
then WITH CHECK on the row as stored"] tx -- "reject, failure or failed check" --> rb["Rolled back: no row, no event"] tx -- passes --> commit["Row and outbox event committed together"] commit --> after["After-hooks, after the commit"] ``` A rule is CEL compiled when the descriptor is applied. Alvo borrows PostgreSQL's row-level security model for the shape: - `list`, `get` and `delete` rules are **row filters** (`USING`): a row the rule excludes is not in the page, and a single row it excludes answers `404`, exactly like a row that does not exist. - `create` rules are **checks on the row being written** (`WITH CHECK`): a row that fails is refused with `403`. - `update` rules are both, from one compiled rule: you can only change a row the rule lets you see, and the row as changed must still satisfy it. A row filter is rendered into SQL. A check on the row being written has no stored row to filter, so it is evaluated in memory over the candidate row, inside the write's transaction; a test proves the two evaluators agree on every expression they can both evaluate. Every read is one statement whose `WHERE` holds the rule's predicate and the tenant scope first; the caller's filter and the page boundary are only ever added with `AND`, fully parenthesised, so no caller input can loosen the policy term. Values come in as bind parameters, never as SQL text. After a before-hook changes a row, the `WITH CHECK` runs again on the changed row, so a hook cannot place a row where the caller could not. The policy sits inside `IAlvoData`, the data port, not in front of it. Your own endpoints call the same port with the caller's context, and get the same answer the generated API gives. [Access rules](https://alvo.burgyn.online/guides/access-rules/) shows the rules at work, and [CEL in Alvo](https://alvo.burgyn.online/concepts/cel/#how-a-rule-becomes-sql) the SQL they become. Rules also stand on applied facts rather than hopes: a role name a rule tests that `auth.roles` does not declare is refused at apply, because a misspelled role would silently admit nobody, or, negated, everybody. ## Hooks fail closed, and stay off the network A before-hook runs inside the transaction of the write it judges, over the row locked for that write: - A `reject` that fires refuses the write with `403 forbidden` and the author's own message. Nothing is written and no event is recorded. - A function that fails, or arithmetic that overflows or divides by zero, aborts the evaluation and the write. A host that renders Alvo's errors answers `500 function-failed`, naming the function and never the host's exception text. - A `mutate` value the target field's declared facets refuse is a `403`, not a silently truncated value. **Nothing a descriptor author writes in a before-hook can reach the network.** The port a before-hook runs through returns no task and takes no cancellation token, so it cannot await anything, and an architecture test fails the build if anything it depends on can reach an HTTP client, a socket or a mail sender. A hook's run time is bounded by its grammar, not by a timeout: a fixed number of expressions, each without loops or I/O. **The exception is a host function** registered with `AddCelFunction`: it is host code, runs inside the transaction with no time budget and no cancellation, and is trusted to be pure and fast; Alvo cannot check that it is. Network work belongs in an after-hook, which runs after the commit from the outbox, holds no lock, and is retried; a webhook is delivered only to a publicly reachable address unless the operator allows a network. ## What an error discloses Every refusal is an RFC 9457 problem document whose `type` names a **kind** of refusal, never its reason, because a reason a client could parse would hand back what the prose is written to withhold: - **`forbidden` is one type for every policy refusal**: no rule, a tenant missing, a `reject`, a `WITH CHECK` failure. The policy engine's own messages name neither the entity nor the row. Two refusals carry more, by design: a `reject` carries its author's message, and a `mutate` value its field refuses names the field and the facet, unless the field is hidden. - **`not-found` is one type for "absent" and "excluded by your rule"**, so reading, updating or deleting by id cannot probe for rows a caller may not see. A `list` rule that excludes rows answers `200` with fewer rows, the way a row filter does. The constraint conflicts under [Where the guarantees stop](#in-short) are the exceptions: a `409` can reveal a row a rule hides. - **`out-of-scope` is a second `403`** only because it is a fact about the caller's own key, with a different fix. - **A `hidden` field's name** is indistinguishable from a field that does not exist on the read surface and in the published document. A caller who may write can still tell the two apart, because a write to a hidden field is accepted and a write to an undeclared one is refused; what it learns is a name, never a value. - **A unique value** on a tenant-scoped entity is unique within its tenant, so a conflict never tells one tenant what another holds; it can still tell a caller about rows of its own tenant a rule hides from it. On an entity that is not tenant-scoped it is unique instance-wide. Both are among the conflicts above. - **A `500 internal`** carries a constant message; the exception goes to the host's log only. The readiness probe answers with a bare phase word, never the failure's text, because it is unauthenticated. The problem types and when each is returned are in [Problem types](https://alvo.burgyn.online/reference/problem-types/), and how a client should branch on them in [Handle errors](https://alvo.burgyn.online/guides/handle-errors/). ## Credentials and requests - **The credential is a header**, `X-Alvo-Api-Key`, so a cross-site form post arrives with no credential and default-deny answers it. Pointing the header at `Cookie` is refused at startup. - **Every body must be declared as JSON.** A body sent as `text/plain`, as a form, or with no `Content-Type` is refused with `415`. A browser sends exactly those cross-site without asking first, so requiring JSON puts every write behind the browser's preflight. It matters most in an embedded host whose own cookie identifies the caller. - **Secrets are files**, never environment values: the bootstrap password and the secret store's key are accepted only as paths to mounted files, and a dev key's secret must be at least 32 characters. :::caution[Not in this build] PostgreSQL's native row-level security as a second line under Alvo's own rules, a change feed that records writes made outside Alvo, an audit log of data changes, issuing and revoking API keys ([#36](https://github.com/Burgyn/MMLib.Alvo/issues/36)), signed webhook deliveries, and rate limiting on the Data and Management APIs are not in this build ([Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/)). ::: ## Put it to work - [Access rules](https://alvo.burgyn.online/guides/access-rules/): write rules and see where a 403 comes from. - [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/): keys, roles and scopes. - [Multi-tenancy](https://alvo.burgyn.online/guides/multi-tenancy/): isolation between tenants. - [Handle errors](https://alvo.burgyn.online/guides/handle-errors/): branch on the problem type. - [Running in production](https://alvo.burgyn.online/guides/production/): secrets, proxies and what to expose. --- # Standalone and embedded Source: https://alvo.burgyn.online/concepts/modes/ Alvo is one engine with two distributions. **Standalone**, it is a container image that turns a mounted descriptor into a running backend, with a dashboard and an API browser. **Embedded**, it is a set of NuGet packages you add to your own ASP.NET Core app, next to your own endpoints and your own users. The standalone image is itself an ASP.NET Core host built from those same packages, so the two cannot drift apart: at its centre are `AddAlvo(…)` and `MapAlvo()`, exactly what an embedded host calls, plus the dashboard, sign-in and an API browser around them. ## Standalone You bring a descriptor and configuration; the image brings everything else. - **The image** reads the descriptor from `/alvo/descriptor.json`, runs on PostgreSQL or SQLite chosen by configuration, and listens on port 8080. It is published as `ghcr.io/burgyn/alvo`; pre-v0.1 it is tagged `edge`, built from `main`. - **The quick start's compose file** runs it over PostgreSQL 16: [Quick start](https://alvo.burgyn.online/start-here/quick-start/) starts it, [Run your own descriptor](https://alvo.burgyn.online/start-here/run-your-own/) points it at yours. - **The dashboard** at `/admin`, with the [schema assistant](https://alvo.burgyn.online/guides/schema-assistant/) when a model is connected, and people who sign in with a password. - **The API browser** at `/scalar`, over the OpenAPI document the descriptor generates at `/openapi/v1.json`. - **The Management API** at `/management`, for agents and scripts. Everything is configuration: API keys, the bootstrap administrator, the database, the startup mode. [Running in production](https://alvo.burgyn.online/guides/production/) covers what to set. ## Embedded Your app owns the process, and Alvo is a part of it. - **Packages**: `MMLib.Alvo` and one database provider, `MMLib.Alvo.Data.Sqlite` or `MMLib.Alvo.Data.PostgreSql`. They are not on NuGet before v0.1; today you reference the projects or pack them to a local feed ([Embed in ASP.NET Core](https://alvo.burgyn.online/start-here/embed/)). - **Registration**: `AddAlvo(alvo => alvo.UseSqlite(…).FromDescriptor(path))`, then map what you want where you want it: the generated Data API under a prefix of your choice, the health probes, the Management API. - **Your authentication**: your users reach Alvo's data through your own endpoints, which pass Alvo the caller and let the descriptor's rules decide ([Use your own authentication](https://alvo.burgyn.online/guides/own-authentication/)). - **Your endpoints**: they call `IAlvoData` in process, under the same rules as the generated API ([Call Alvo from your endpoints](https://alvo.burgyn.online/guides/call-from-endpoints/)). - **Your C#**: functions a descriptor's hooks can call, registered with `AddCelFunction` ([Custom CEL functions](https://alvo.burgyn.online/guides/custom-cel-functions/)). The dashboard (`MMLib.Alvo.Admin`) and the schema assistant (`MMLib.Alvo.Ai`) are separate packages a host can add; the one-file host in [Embed Alvo in ASP.NET Core](https://alvo.burgyn.online/start-here/embed/) adds them as an optional step. ## The same descriptor runs in both The descriptor is the same file in either mode: the image mounts it, an embedded host passes its path to `FromDescriptor`. The repository proves it rather than claiming it. `examples/vehicle-registry` is the descriptor the compose stack serves **and** the one the embedded sample, [`samples/MMLib.Alvo.Samples.EmbeddedHost`](https://github.com/Burgyn/MMLib.Alvo/tree/main/samples/MMLib.Alvo.Samples.EmbeddedHost), runs; the sample's integration suite boots both and asserts that they generate the same routes. So moving from standalone to embedded is carrying the file over. The signal to move is needing code: your own functions, endpoints, identity or providers. That is an upgrade path, not a limit of the standalone mode. ## What differs | | Standalone | Embedded | |---|---|---| | Distribution | the container image `ghcr.io/burgyn/alvo` (`edge` until v0.1) | NuGet packages, project references until v0.1 | | Descriptor | the file mounted at `/alvo/descriptor.json` | the path you pass to `FromDescriptor` | | Database | PostgreSQL or SQLite, chosen by `Alvo:Database:Provider` | the provider package you register | | Generated API | at `/api` | under the prefix you choose (`/api/alvo` in [Embed Alvo in ASP.NET Core](https://alvo.burgyn.online/start-here/embed/)), with your own conventions attached | | Who calls the generated API | API keys from configuration | API keys; your own users go through your endpoints | | Custom CEL functions | built-in functions only | built-ins plus your `AddCelFunction` registrations | | Your own endpoints | none | yes, through `IAlvoData` | | OpenAPI document and API browser | `/openapi/v1.json` and `/scalar`, switched by `Alvo:Docs:Enabled` | none of its own: call `AddOpenApi()` and `MapOpenApi()`, and Alvo's routes and schemas appear in your document | | Dashboard and schema assistant | included, at `/admin` | add `MMLib.Alvo.Admin` and `MMLib.Alvo.Ai` | | Configuration | `Alvo:*` keys, environment variables in the container | the same options, bound or set in code | | Errors | every problem type | every problem type except `unreadable-request`, `internal` and `function-failed`, which need `AddAlvoProblemDetails()` | | Health probes | `/health/live` and `/health/ready` | the same two, where you map them | The error row matters for clients. Alvo's own endpoints answer their refusals as problem documents in both modes. The three types in that row are produced by Alvo's exception handler, which an embedded host opts into with `AddAlvoProblemDetails()`; without it, your host renders a request the web server refused, a failure inside Alvo, or a failing CEL function its own way. [Handle errors](https://alvo.burgyn.online/guides/handle-errors/#errors-in-an-embedded-host) shows both. :::caution[Not in this build] A host cannot yet publish its own signed-in user to the generated API's routes: they authenticate API keys only, and your users go through your own endpoints ([#210](https://github.com/Burgyn/MMLib.Alvo/issues/210)). One host serves one project ([#141](https://github.com/Burgyn/MMLib.Alvo/issues/141)). ::: ## Put it to work - [Quick start](https://alvo.burgyn.online/start-here/quick-start/): the standalone stack in a few minutes. - [Embed in ASP.NET Core](https://alvo.burgyn.online/start-here/embed/): Alvo inside your app. - [Running in production](https://alvo.burgyn.online/guides/production/): configure the standalone host for real use. - [Architecture](https://alvo.burgyn.online/concepts/architecture/): the packages both modes are built from. --- # Dynamic entities Source: https://alvo.burgyn.online/concepts/dynamic-entities/ :::caution[Not in this build] Dynamic entities are **planned** for a later phase. The `dynamicEntities` block of the descriptor is parsed and accepted, and nothing runs: no runtime entity can be created, and every limit the block declares bounds nothing. An entity declared with `storage: dynamic` is not created either. [Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/#declared-but-not-run-in-this-build) says so in the framework's own words. This page describes the design the rest of Alvo is already built to accommodate. ::: ## The problem Some applications have to let **their own users** define what they record. An ERP built on Alvo has a customer who says "I need a register of company vehicles, with a plate number, a VIN and an owner", and another who needs a register of service contracts. Nobody should run a schema migration for that, and the developer should not have to ship a release. The obvious answer, a database table per user-defined type, breaks down quickly. A thousand customers with a dozen registers each is twelve thousand tables: the database's catalog bloats, its planner and maintenance slow down, and every new register is live schema change, with its locks and its failure modes, triggered by an end user, repeatedly. ## One shared store, never a table per entity The planned design is the one Salesforce, Airtable and Microsoft Dataverse use: a **metadata-driven store**. A fixed, small set of tables holds everything, whatever users create: - the **definitions** of the record types and of their fields: names, types, which fields are required, references; - the **records** of every dynamic type of every tenant, in **one shared table** partitioned by tenant, each record's values stored as JSON. Creating a record type is then inserting a row of metadata: no schema change, no lock on existing tables, and ten thousand new types do not add a single database object. The cost is honest too. Aggregations and joins over JSON values are slower than over real columns, so a type that grows large or reporting-heavy needs its hot fields indexed, or eventually its own table. Because the database enforces no types or references inside the JSON, the API layer has to validate every value against the definitions before it is written. And a dynamic type has no compiled C# type: it is read and written as JSON. ## One model, two drivers The part that is already built is the reason this can be added without rewriting Alvo. Everything above the data layer, the generated API, the rule engine and events, works from one abstract model of entities and fields that a **schema registry** supplies. Today it has one driver, which reads the physical tables Alvo created from the descriptor. The plan adds a second driver that reads the metadata tables and produces the same model. So a dynamic entity is meant to be indistinguishable from a physical one to everything above the store: the same routes, the same [CEL rules](https://alvo.burgyn.online/concepts/cel/) compiled to SQL, the same tenancy, the same events. The seam is already in place where it matters most: SQL for a rule is generated through a dialect interface that renders each field reference, so a field stored as a JSON path can be rendered there without touching the rest of the rule engine. The acceptance bar for the feature is that the same adversarial and policy test suite passes, identically, over physical and dynamic entities. ## How it is governed Dynamic entities are an embedded-mode feature, for a host whose end users create the types. The host decides the policy over that whole class of entities in the descriptor's [`dynamicEntities`](https://alvo.burgyn.online/reference/descriptor/dynamic-entities/) block: whether it is enabled, a reserved name prefix so a user's type can never collide with one the descriptor declares, the default rules every new type starts with, the field types users may choose, and limits on fields, records and types per tenant. Today that block is validated against the schema and then does nothing. ## Put it to work - [Entities and fields](https://alvo.burgyn.online/guides/entities-and-fields/): physical entities, which work today. - [Multi-tenancy](https://alvo.burgyn.online/guides/multi-tenancy/): the tenant isolation dynamic entities will share. - [Roadmap and status](https://alvo.burgyn.online/project/roadmap/): where dynamic entities sit in the plan. - [`dynamicEntities` reference](https://alvo.burgyn.online/reference/descriptor/dynamic-entities/): the block's keys. --- # Architecture Source: https://alvo.burgyn.online/concepts/architecture/ Alvo has two paths. The **control path** changes what the backend is: a descriptor is checked, the database is migrated and the rules are compiled. The **runtime path** serves requests against what the control path produced. Both run in one process, in the standalone image and in your own ASP.NET Core host alike. ```mermaid flowchart TB accTitle: How Alvo fits together accDescr: Control path, the dashboard and agents call the Management API, which, like the descriptor file, feeds the schema registry. Runtime path, an HTTP request passes authentication, the rules compiled to SQL, then a transaction that runs the before-hooks and writes the row with its outbox event, then the after-hooks. The schema registry supplies the rules and the hooks. subgraph Control direction LR clients["Dashboard · agents"] --> mgmt["Management API"] file["Descriptor (JSON)"] --> registry["Schema registry"] mgmt --> registry end subgraph Runtime direction TB request["HTTP request"] --> auth["Auth: API key → @user"] auth --> rules["Rules: CEL → SQL"] rules --> tx["Transaction: before-hooks, row + outbox"] tx --> after["After-hooks"] end registry -. "rules" .-> rules registry -. "hooks" .-> tx ``` ## The control path A descriptor arrives from a file at boot, from the [Management API](https://alvo.burgyn.online/reference/management-api/), or from the dashboard, which calls the same API in process. Every door leads to the same steps: 1. **Validate.** The JSON Schema, then the semantic checks, then every CEL expression compiled in its profile against its entity ([The project descriptor](https://alvo.burgyn.online/concepts/descriptor/#how-a-descriptor-is-checked)). 2. **Plan.** The new schema is compared with the one applied last, recorded in the database, not re-read from live tables. A plan that would discard data stops here unless the apply allows it. 3. **Apply and record.** The schema change and the new revision in the descriptor history are written as one unit, so a lost race cannot leave the schema changed and the change unrecorded. 4. **Prime.** The rules, hooks and role catalog are compiled from the accepted descriptor into the policy catalog every request reads. The generated routes are built from the applied schema when the first request arrives, and then kept. Everything a route reads per request follows a runtime apply on the next request: rules, hooks, and an entity's fields, facets and formats, read from the same revision the request's policy decision was taken against. Only the routes themselves are fixed: an entity added through the Management API or the dashboard gets no route until the process restarts ([#103](https://github.com/Burgyn/MMLib.Alvo/issues/103)). ## The runtime path Every generated route is a minimal-API endpoint, and every one takes the same steps: - **Authenticate.** The API key header resolves to a caller: a user id, roles and at most one tenant. No key means the anonymous caller, judged by the same rules. An unusable key is a `401`. - **Scope.** The key's scopes must cover the entity and the operation, or the answer is `403 out-of-scope`. - **Decide.** The policy engine resolves the operation's rule, the tenant scope and the field masks into a decision, or refuses it with `403 forbidden`. - **Read** in one SQL statement whose `WHERE` holds the rule, the tenant scope, then the caller's filter and the page boundary, each `AND`-ed on and parenthesised. - **Write** in one transaction: the before-hooks run over the row locked for the write, the rule's `WITH CHECK` judges the row as it will be stored, then the row and its event are written together and committed. - **After the commit**, a background dispatcher delivers the event to the after-hooks. Your own endpoints in an embedded host enter at **Decide**: they call `IAlvoData` with the caller's context, and the same decision is made inside the port. [Security model](https://alvo.burgyn.online/concepts/security-model/) says what each step guarantees. ## Ports and the provider model The core never references a concrete database, mail server or identity provider. It talks to **ports**, interfaces in `MMLib.Alvo.Abstractions`, and a provider package plugs an implementation in with an extension method on the one entry point, `AddAlvo(alvo => alvo.UsePostgreSql(…))`. A new provider is a new package, never an edit to the core. | Port | What it does | |---|---| | `IAlvoData` | reads and writes rows, with the caller's context; the policy is enforced inside it, not around it | | `IPolicyEngine` | turns rules, the tenant scope and field masks into a decision; default-deny throughout | | `ISchemaRegistry` | supplies the entity model everything above it works from | | `ISchemaMigrator` | plans and applies a schema change | | `IAppliedSchemaStore` | keeps the schema applied last, the "current" side of every plan | | `IDescriptorVersionStore` | keeps the append-only descriptor history, the rollback targets | | `IDescriptorSource` | loads the descriptor, from a file today | | `IFieldSqlRenderer` | a database dialect's half of rendering a rule: identifiers, literals, the null fold | | `IOutboxStore` | the event queue: append inside the write, claim, mark delivered | | `IAlvoContextResolver` | turns a presented credential into a caller | | `ISecretStore` | the values a descriptor may only name, such as an API key for the AI connection | | `IEmailSender` | where an `email` after-hook delivers | | `IAlvoManagement` | every Management API operation, which the dashboard and the schema assistant call in process | The two database providers share one EF Core-based implementation and differ in their dialect. SQL generation for a rule is split the same way: the structure (`AND`, `OR`, `NOT`) is the core's, and everything a database spells differently goes through `IFieldSqlRenderer`. That split is what keeps rules, events and tenancy identical on SQLite and PostgreSQL, and it is where the planned [dynamic entities](https://alvo.burgyn.online/concepts/dynamic-entities/) will plug in a renderer for a field stored as JSON. ## Events and the outbox Every write records an event in the same transaction as the row, in an outbox table: no change without an event, and no event without a change. An event takes five stages: | Stage | What happens | |---|---| | **emit** | inside the write's transaction, on the same connection, one row is appended to the outbox | | **claim** | a dispatcher takes the oldest undelivered entries with one statement and counts the attempt | | **deliver** | the after-hooks subscribed to the event run their `webhook` or `email` action | | **mark** | the entry is stamped as delivered, only after every matched hook ran | | **retire** | the entry stays: nothing deletes it, so an abandoned event remains countable | A delivery that fails releases the entry for another attempt, up to a ceiling, after which it is left alone and still observable. A process killed mid-delivery repeats the action after a restart. That makes delivery **at-least-once**, so every receiver must be idempotent: the event's `id` is the one value stable across redeliveries, and the key to deduplicate on. The envelope is CloudEvents 1.0. There is no global order. Events for one row are delivered in order only while a single dispatcher runs **and** no two events for that row are written within the same millisecond by different processes; a second instance delivering events breaks the order silently, so run one ([Running in production](https://alvo.burgyn.online/guides/production/#run-more-than-one-instance)). [After-hooks, events and webhooks](https://alvo.burgyn.online/guides/after-hooks-and-webhooks/) shows what a receiver gets. ## Packages A package is earned, not assumed: code becomes its own package only when it pulls a heavy dependency most users do not want, is a real swap point, or ships differently. Everything else is a feature folder inside the core, organized by feature rather than by technical layer. | Package | Holds | |---|---| | [`MMLib.Alvo.Abstractions`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-abstractions/) | the ports and the schema model; the root every other package depends on | | [`MMLib.Alvo`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo/) | the core: descriptor, migrations, rule engine and CEL, events, the generated Data API and the Management API | | [`MMLib.Alvo.Data.EntityFrameworkCore`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-data-entityframeworkcore/) | the shared EF Core implementation of the data ports | | [`MMLib.Alvo.Data.Sqlite`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-data-sqlite/), [`MMLib.Alvo.Data.PostgreSql`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-data-postgresql/) | the two database providers | | [`MMLib.Alvo.Identity`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-identity/) | the people who sign in to the dashboard, and the bootstrap administrator | | [`MMLib.Alvo.Admin`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-admin/) | the admin dashboard, which reaches the core only through the Management API's port | | [`MMLib.Alvo.Ai`](https://alvo.burgyn.online/reference/csharp/mmlib-alvo-ai/) | the schema assistant, which also sees only the ports | The standalone host is not a package: it is the container image, composed from these with both database drivers and an API browser. Architecture tests keep the boundaries, for example that the dashboard holds no reference to the core. ## Put it to work - [Standalone and embedded](https://alvo.burgyn.online/concepts/modes/): the two ways to run the same engine. - [Embed in ASP.NET Core](https://alvo.burgyn.online/start-here/embed/): compose the packages in your own host. - [Call Alvo from your endpoints](https://alvo.burgyn.online/guides/call-from-endpoints/): use `IAlvoData` directly. - [C# API](https://alvo.burgyn.online/reference/csharp/): every public type. - Design notes: [package boundary](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/package-boundary.md), [event backbone](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/events.md), [extensibility](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/extensibility.md). --- # Glossary Source: https://alvo.burgyn.online/concepts/glossary/ The words these docs use with a precise meaning, in alphabetical order. Each links to the page that teaches it.
Access level
One of three levels on the Management API and the dashboard: `viewer` reads, `developer` also applies and rolls back, `admin` also changes the `access` block, the people and the AI connection. The descriptor's `access` block maps roles to levels, and the highest match wins. [For coding agents](https://alvo.burgyn.online/start-here/coding-agents/#access-levels)
Access profile
The CEL profile of the `access` block's three levels: a boolean over `@user` alone, with no row and no tenant. [CEL in Alvo](https://alvo.burgyn.online/concepts/cel/#the-five-profiles)
After-hook
An action that runs after a write has committed, delivered from the outbox: a webhook or an e-mail. It may reach the network and is retried, so its receiver must tolerate a repeat. [After-hooks, events and webhooks](https://alvo.burgyn.online/guides/after-hooks-and-webhooks/)
Apply
Making a descriptor the running one: validate it, plan the migration from the schema applied last, run it, and record a new revision. It happens on boot, through the Management API, or from the dashboard; a dry run does everything but the last two steps. [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/)
Before-hook
A `reject` or `mutate` action that runs inside a write's transaction, before the row is stored. Nothing a descriptor can express in it reaches the network (a function a host registers in C# is host code and the exception), and if it refuses or fails, nothing is written. [Validate and transform writes (before-hooks)](https://alvo.burgyn.online/guides/before-hooks/)
Bootstrap administrator
The first dashboard account, created once from configuration rather than from the descriptor. It always holds the `admin` level, so a project can never lock everyone out. [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/#people-sign-in-to-the-dashboard)
CEL profile
The set of CEL constructs an expression may use, decided by where it stands in the descriptor. There are five: Rule, Computed, Condition, Mutate and Access. [CEL in Alvo](https://alvo.burgyn.online/concepts/cel/#the-five-profiles)
Computed field
A field whose value the database derives from the same row, written in the **Computed** profile; callers cannot write it. [Computed fields and rollups](https://alvo.burgyn.online/guides/computed-and-rollups/)
Computed profile
The CEL profile of a `computed` field: a value the database computes from the same row, with no caller context, and the only profile with the `? :` conditional. [CEL in Alvo](https://alvo.burgyn.online/concepts/cel/#the-five-profiles)
Condition profile
The CEL profile of a hook's `condition`: a boolean that sees the row before and after the write (`old.`, `new.`) and may call `changed(field)`. [CEL in Alvo](https://alvo.burgyn.online/concepts/cel/#the-five-profiles)
Descriptor
The one JSON document that defines a backend: entities, fields, rules, hooks, derived values and management access, never infrastructure or credentials. [The project descriptor](https://alvo.burgyn.online/concepts/descriptor/)
Dry run
An apply that stops before changing anything (`?dryRun=true` on the Management API): it answers with the plan, or with the refusal a real apply would give. [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/)
Embedded
Running Alvo as NuGet packages inside your own ASP.NET Core app, beside your own endpoints and users. [Standalone and embedded](https://alvo.burgyn.online/concepts/modes/)
Entity
One kind of record the descriptor declares, such as `tickets`: a table in the database and its own routes in the Data API. [Entities and fields](https://alvo.burgyn.online/guides/entities-and-fields/)
Honoured
A descriptor block this build runs as documented. The others are warned or refused. [Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/)
Mutate profile
The CEL profile of a before-hook's `mutate` values: a value a field can hold, computed from the row, functions and arithmetic. [CEL in Alvo](https://alvo.burgyn.online/concepts/cel/#the-five-profiles)
new, old
In a hook, the row as it will be stored (`new.status`) and as it was before the write (`old.status`). Only the Condition and Mutate profiles can read them. [Validate and transform writes (before-hooks)](https://alvo.burgyn.online/guides/before-hooks/)
Outbox
The table every write appends its event to, in the same transaction as the row, so there is no change without an event and no event without a change. After-hooks are delivered from it. [Architecture](https://alvo.burgyn.online/concepts/architecture/#events-and-the-outbox)
Problem type
The slug at the end of a refusal's `type` (`https://alvo.dev/errors/forbidden`) that names its kind. Clients branch on it, never on the prose in `detail`. [Problem types](https://alvo.burgyn.online/reference/problem-types/)
Refused
A descriptor feature the schema allows but this build rejects at apply, with a reason and a fix, because accepting it would silently do something other than what it says. [Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/#refused-at-apply)
Revision
One entry in a project's append-only descriptor history: the descriptor, its number, who applied it, when and why. Every apply adds one, a rollback included, and none is rewritten. Do not confuse it with `apiVersion`, the format's version. [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/)
Role
A name a caller holds, such as `agent`, that rules test with `'agent' in @user.roles`. The descriptor declares its roles in `auth.roles`; `anon`, `authenticated` and `admin` are built in. A key carrying a role the descriptor does not declare authenticates nothing. [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/)
Rollup
A field that aggregates related rows, such as the sum of an order's lines, kept up to date inside the same transaction as the change. [Computed fields and rollups](https://alvo.burgyn.online/guides/computed-and-rollups/)
Rule
A CEL expression per entity operation (`list`, `get`, `create`, `update`, `delete`) that decides which rows a caller may reach: rendered into the SQL that reads rows, and checked against the row a write would store. An operation without one is refused. [Access rules](https://alvo.burgyn.online/guides/access-rules/)
Rule profile
The CEL profile of rules and of `hidden` and `readOnly` flags: a boolean over the current row, `@user` and `@tenant`, with no arithmetic. [CEL in Alvo](https://alvo.burgyn.online/concepts/cel/#the-five-profiles)
Scope
A limit written on an API key, `:`, that narrows what the key reaches on the Data API whatever the rules allow. Scopes do not limit the Management API, where a key reaches what its roles reach. [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/#3-narrow-a-key-with-scopes)
Standalone
Running Alvo as a container image driven by a mounted descriptor, with the dashboard and an API browser included. [Standalone and embedded](https://alvo.burgyn.online/concepts/modes/)
Tenant, @tenant
The customer or organisation a caller acts for, at most one per caller. On a tenant-scoped entity every row belongs to one tenant, and `@tenant.id` is the caller's tenant in a rule. [Multi-tenancy](https://alvo.burgyn.online/guides/multi-tenancy/)
@user
The caller in an expression: `@user.id` and `@user.roles`, and nothing else. An expression that reads `@user.id` refuses a caller with no identity. [Access rules](https://alvo.burgyn.online/guides/access-rules/)
Warned
A descriptor block this build parses and accepts but does not run. The apply succeeds, and the dashboard and the capabilities answer say what does not happen. [Capabilities in this build](https://alvo.burgyn.online/reference/capabilities/#declared-but-not-run-in-this-build)
Working copy
The dashboard's one unapplied draft of the descriptor, which every screen and the schema assistant edit. Nothing changes until you preview and apply it. [The admin dashboard](https://alvo.burgyn.online/guides/admin-dashboard/#3-change-the-schema)
--- # Descriptor reference Source: https://alvo.burgyn.online/reference/descriptor/ **Guide:** [The project descriptor](https://alvo.burgyn.online/concepts/descriptor/) Declarative definition of an Alvo backend. One artifact for every path: Docker mount, `alvo apply`, Management API, AddAlvo().FromDescriptor(), and admin UI export. Conditions (rules, hook/automation conditions) use a CEL subset; payload transformations use JSONata. Env/secrets (connection strings, admin credentials) do NOT belong here — the descriptor defines the backend, not the infrastructure configuration. ## Top-level keys Blocks, each documented on its own page: - [`branding`](https://alvo.burgyn.online/reference/descriptor/branding/) — Identity of THIS project/backend (display name, logo), shown wherever the project is presented: its section in the admin dashboard, generated end-user surfaces, transactional emails. - [`tenancy`](https://alvo.burgyn.online/reference/descriptor/tenancy/) — Declares this backend as multi-tenant (definition, not infrastructure). - [`dynamicEntities`](https://alvo.burgyn.online/reference/descriptor/dynamic-entities/) — Governance for runtime, user-defined entities (the dynamic schema-registry driver). - [`auth`](https://alvo.burgyn.online/reference/descriptor/auth/) — Authentication and application roles. - [`access`](https://alvo.burgyn.online/reference/descriptor/access/) — Who may manage THIS project in the admin dashboard (project-scoped), mapped to management levels by ROLE MEMBERSHIP. - [`entities`](https://alvo.burgyn.online/reference/descriptor/entities/) — Entity definitions. - [entities · fields](https://alvo.burgyn.online/reference/descriptor/entities-fields/) - [entities · computed & rollups](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/) - [entities · rules](https://alvo.burgyn.online/reference/descriptor/entities-rules/) - [entities · hooks](https://alvo.burgyn.online/reference/descriptor/entities-hooks/) - [entities · indexes](https://alvo.burgyn.online/reference/descriptor/entities-indexes/) - [`automation`](https://alvo.burgyn.online/reference/descriptor/automation/) — ECA rules (event–condition–action). - [`templates`](https://alvo.burgyn.online/reference/descriptor/templates/) — Reusable message templates referenced by email/notification actions. - [`formats`](https://alvo.burgyn.online/reference/descriptor/formats/) — Reusable named validation formats referenced by field.format (beyond the built-ins email/uri/phone). - [`webhooks`](https://alvo.burgyn.online/reference/descriptor/webhooks/) — Managed webhook endpoints (Standard Webhooks: HMAC signing, retries, DLQ). - [`functions`](https://alvo.burgyn.online/reference/descriptor/functions/) — Custom logic — csx scripts (standalone). Keys matching `^x-` are accepted. Extension keys. Alvo ignores these but guarantees passthrough through apply -> export. Use for host/tooling metadata (UI hints, provenance). ### `$schema` Optional editor hint pointing at this schema; ignored by Alvo. - **Type:** `string` - **Required:** no ### `apiVersion` Descriptor format version. Self-describing so a loader can dispatch the right parser before fetching the schema. Within v1 the format evolves additively only; a breaking change becomes alvo.dev/v2 at a new URL. - **Type:** `string` - **Required:** yes - **Values:** `"alvo.dev/v1"` ### `name` Project identifier (kebab-case). In standalone mode it identifies the project within the instance. - **Type:** `string` - **Required:** yes - **Pattern:** `^[a-z][a-z0-9-]{1,62}$` ### `description` Human-readable description of this project/backend (what it is), surfaced to agents and in the admin UI. - **Type:** `string` - **Required:** no ### `revision` Content revision counter, incremented on every applied change; used for optimistic concurrency during apply. NOT the format version (that is apiVersion). - **Type:** `integer` - **Required:** no --- # branding Source: https://alvo.burgyn.online/reference/descriptor/branding/ **Guide:** [The project descriptor](https://alvo.burgyn.online/concepts/descriptor/) ### `branding` Identity of THIS project/backend (display name, logo), shown wherever the project is presented: its section in the admin dashboard, generated end-user surfaces, transactional emails. This is NOT the instance-wide dashboard chrome — one Alvo instance can host many projects, so dashboard branding is host/env config, not a per-project descriptor concern. - **Type:** `object` - **Required:** no ## Keys ### `branding.title` Display name of this project/backend. - **Type:** `string` - **Required:** no ### `branding.logoUrl` URL or path to this project's logo. - **Type:** `string` - **Required:** no --- # tenancy Source: https://alvo.burgyn.online/reference/descriptor/tenancy/ **Guide:** [Multi-tenancy](https://alvo.burgyn.online/guides/multi-tenancy/) ### `tenancy` Declares this backend as multi-tenant (definition, not infrastructure). Tenant resolution (subdomain/header/claim) is host/env config, never here. When enabled, entities default to tenancy:scoped unless they opt out with tenancy:global. - **Type:** `object` - **Required:** no ## Keys ### `tenancy.enabled` Turn on row-level multi-tenancy (shared DB + shared schema, discriminator column). - **Type:** `boolean` - **Required:** no - **Default:** `false` --- # dynamicEntities Source: https://alvo.burgyn.online/reference/descriptor/dynamic-entities/ :::caution[Not run in this build] no runtime entity can be created and the whole dynamic schema-registry driver is absent, so every governance limit declared here bounds nothing (planned) ::: **Guide:** [Dynamic entities](https://alvo.burgyn.online/concepts/dynamic-entities/) ### `dynamicEntities` Governance for runtime, user-defined entities (the dynamic schema-registry driver). End-users create record types at runtime via the Management API into metadata tables — they are NOT declared under `entities`. This block is how the host expresses the security policy and limits over that whole class. The store itself is planned and not in this build. - **Type:** `object` - **Required:** no ## Keys ### `dynamicEntities.enabled` Allow end-users to define entities at runtime. - **Type:** `boolean` - **Required:** no - **Default:** `false` ### `dynamicEntities.namePrefix` Reserved name prefix for runtime-created entities, so they can never collide with descriptor-defined entity names. - **Type:** `string` - **Required:** no - **Pattern:** `^[a-z][a-z0-9_]{0,15}_$` ### `dynamicEntities.defaultRules` Authorization rules applied to EVERY runtime-created entity. A virtual entity must carry policy exactly as a physical one carries `rules`; without this, default-deny makes user entities unreachable (or, if defaulted open, insecure). Missing operation = deny. - **Type:** `object` - **Required:** no ### `dynamicEntities.defaultRules.list` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no ### `dynamicEntities.defaultRules.get` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no ### `dynamicEntities.defaultRules.create` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no ### `dynamicEntities.defaultRules.update` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no ### `dynamicEntities.defaultRules.delete` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no ### `dynamicEntities.allowedFieldTypes` The subset of field types end-users may use on runtime entities. - **Type:** `array of string` - **Required:** no ### `dynamicEntities.maxFieldsPerEntity` Per-entity field quota for runtime entities. - **Type:** `integer` - **Required:** no ### `dynamicEntities.maxRecordsPerEntity` Per-entity record quota for runtime entities (per tenant). - **Type:** `integer` - **Required:** no ### `dynamicEntities.maxEntitiesPerTenant` How many runtime entities a single tenant may create. - **Type:** `integer` - **Required:** no ### `dynamicEntities.defaultTenancy` Default tenancy for runtime-created entities. - **Type:** `string` - **Required:** no - **Values:** `"scoped"`, `"global"` - **Default:** `"scoped"` --- # auth Source: https://alvo.burgyn.online/reference/descriptor/auth/ :::caution[Not run in this build] `auth.providers` — only local credentials exist in this build — a person signs in with an address and a password Alvo holds; google, microsoft, github, apple and oidc sign-in are planned (#36), so a declared provider other than local offers no way in ::: **Guide:** [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/) ### `auth` Authentication and application roles. - **Type:** `object` - **Required:** no ## Keys ### `auth.providers` Enabled identity providers. `local` = credentials managed by Alvo (ASP.NET Core Identity). `oidc` = a generic OIDC relying party — this is the path for a custom / self-hosted identity provider (Keycloak, Auth0, Okta, Entra ID, …); its endpoints are infra/env, never here. - **Type:** `array of string` - **Required:** no ### `auth.roles` Application roles beyond the built-in ones (anon, authenticated, admin). Referenced in CEL via @user.roles (a set; test membership with 'editor' in @user.roles). - **Type:** `array of string` - **Required:** no --- # access Source: https://alvo.burgyn.online/reference/descriptor/access/ **Guide:** [For coding agents](https://alvo.burgyn.online/start-here/coding-agents/) ### `access` Who may manage THIS project in the admin dashboard (project-scoped), mapped to management levels by ROLE MEMBERSHIP. Each level is a CEL predicate over the closed context @user exposes — @user.id and @user.roles, nothing else. In practice a level tests ROLE MEMBERSHIP: @user.id is admitted, but Alvo's CEL grammar has no uuid literal and a level sees no row, so there is no uuid-typed operand to compare it against and '@user.id == ...' is refused as a type error rather than by this block. It stays admitted deliberately, so a level written against a future typed claim compiles unchanged. Attribute-based rules (an email domain, a team) are NOT expressible in this or any other block; typed claims are tracked by #37, and widening @user is additive, so a role-based level keeps compiling once they land. Every declared level is COMPILED AT APPLY, in the same pass as every rule and against the same declared roles: a level referring to a role that auth.roles does not declare is refused there, with the same 'did you mean' suggestion a rule's typo gets. The three levels are independent predicates resolved HIGHEST MATCH WINS (admin > developer > viewer), evaluated in memory and never rendered to SQL; a caller matching none is refused every management operation that carries the gate, and the deployment's bootstrap administrator is an admin whatever this block says — so a descriptor with no access block admits nobody else. - **Type:** `object` - **Required:** no ## Keys ### `access.admin` CEL over @user.roles / @user.id: who may fully administer this project (schema, rules, data, settings). - **Type:** `string` - **Required:** no ### `access.developer` CEL over @user.roles / @user.id: who may edit this project's schema, rules, and automation, but not its settings. - **Type:** `string` - **Required:** no ### `access.viewer` CEL over @user.roles / @user.id: who may view this project's data and configuration read-only. - **Type:** `string` - **Required:** no --- # entities Source: https://alvo.burgyn.online/reference/descriptor/entities/ :::caution[Not run in this build] `entity.storage` — an entity declared with 'storage: dynamic' is not created: this build has no dynamic schema-registry driver (planned, #41), so the entity gets no table, no Data API route and no records, and the apply drops it without refusing it `entity.realtime` — no change is published over a realtime channel, because this build has none (planned, #38) — whatever an entity's 'realtime' says, and its default is true, nothing is sent and nothing can subscribe ::: :::danger[Refused at apply] `entity.softDelete` — Soft delete is not supported yet: a delete would remove the row outright and reads would not exclude it, which is irrecoverable data loss where the schema promises recoverability. **Fix:** Remove 'softDelete' or track the soft-delete implementation issue. A flag written as false is not a declaration and maps normally. ::: **Guide:** [Entities and fields](https://alvo.burgyn.online/guides/entities-and-fields/) The `entities` block spans these pages: **entities** · [entities · fields](https://alvo.burgyn.online/reference/descriptor/entities-fields/) · [entities · computed & rollups](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/) · [entities · rules](https://alvo.burgyn.online/reference/descriptor/entities-rules/) · [entities · hooks](https://alvo.burgyn.online/reference/descriptor/entities-hooks/) · [entities · indexes](https://alvo.burgyn.online/reference/descriptor/entities-indexes/). ### `entities` Entity definitions. Key = entity name (snake\_case, plural recommended). Each entity auto-generates a CRUD API, validation, and a schema registry record. The name `users` is reserved (the built-in auth entity). - **Type:** `map of object` - **Required:** yes - Names match `^[a-z][a-z0-9_]{0,62}$`. - Reserved name: `users` is not allowed. ## Keys ### `entities..description` Human-readable description of the entity (surfaced to agents and in the admin UI). - **Type:** `string` - **Required:** no ### `entities..renamedFrom` Previous name of this entity: declares a rename so apply preserves data instead of drop+add. Safe to leave permanently; ignored once the old name no longer exists. - **Type:** `string` - **Required:** no - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` ### `entities..storage` physical = a real table (introspected/migrated); dynamic = the shared metadata-driven store (no DDL, instant apply). Everything above the registry treats both identically. - **Type:** `string` - **Required:** no - **Values:** `"physical"`, `"dynamic"` - **Default:** `"physical"` ### `entities..tenancy` scoped = rows carry tenant\_id and are isolated per tenant (default when tenancy.enabled); global = shared reference data (číselník) visible to all tenants. - **Type:** `string` - **Required:** no - **Values:** `"scoped"`, `"global"` ### `entities..softDelete` Framework-managed soft delete: a managed deleted\_at column, DELETE becomes a soft delete, and reads/list/get/rollup auto-exclude soft-deleted rows. A restore operation is provided. - **Type:** `boolean` - **Required:** no - **Default:** `false` ### `entities..audit` Inject framework-managed audit columns: created\_at, created\_by, updated\_at, updated\_by. - **Type:** `boolean` - **Required:** no - **Default:** `false` ### `entities..realtime` Publish changes over the realtime channel (with subject-level authz). - **Type:** `boolean` - **Required:** no - **Default:** `true` --- # entities.fields Source: https://alvo.burgyn.online/reference/descriptor/entities-fields/ :::danger[Refused at apply] `field.validation` — Field 'validation' is not evaluated yet, so a value the expression forbids is accepted — the field is not constrained at all. **Fix:** Remove 'validation'. Enforce the rule in a before-hook once #22 lands, or express it with a facet the API does validate — 'maxLength', 'precision'/'scale', enum 'values' or a 'format'. `field.default` — Field 'default' is honoured as a literal, but not as a '$cel' expression: a CEL default is evaluated against the caller's context at insert time, which is the 'computed' machinery rather than a column default — so the value would be dropped and the field left null. **Fix:** Declare a literal default, which this build emits as a column DEFAULT, or remove 'default' and send the value explicitly on create (#113). ::: **Guide:** [Entities and fields](https://alvo.burgyn.online/guides/entities-and-fields/) The `entities` block spans these pages: [entities](https://alvo.burgyn.online/reference/descriptor/entities/) · **entities · fields** · [entities · computed & rollups](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/) · [entities · rules](https://alvo.burgyn.online/reference/descriptor/entities-rules/) · [entities · hooks](https://alvo.burgyn.online/reference/descriptor/entities-hooks/) · [entities · indexes](https://alvo.burgyn.online/reference/descriptor/entities-indexes/). ## Keys ### `entities..fields` Entity fields. The `id` field (uuid, PK) is always added by the framework and cannot be declared, nor can the columns the entity's traits add (`tenant_id`, the audit columns, `deleted_at`). Key = field name (snake\_case). The names `order`, `limit`, `offset`, `after`, `select`, `or`, `and` and `not` are reserved and are rejected when the descriptor is applied: the generated Data API's query string gives each of them a meaning (`?limit=10`, `?or=(...)`, `?not.color=eq.red`), so a request could not tell a filter on such a field from the parameter itself. The pattern above cannot express that exclusion, which is why it is stated here rather than validated by the schema — rename the field. - **Type:** `map of object` - **Required:** yes - Names match `^[a-z][a-z0-9_]{0,62}$`. ### `entities..fields..type` The field's storage and validation type. `decimal` needs `precision` and `scale`; `enum` needs `values`; `ref` needs `entity`. - **Type:** `string` - **Required:** yes - **Values:** `"string"`, `"text"`, `"integer"`, `"decimal"`, `"boolean"`, `"date"`, `"datetime"`, `"uuid"`, `"json"`, `"enum"`, `"ref"` ### `entities..fields..description` Human-readable description of the field (surfaced to agents and in the admin UI). - **Type:** `string` - **Required:** no ### `entities..fields..renamedFrom` Previous name of this field: declares a rename so apply preserves data instead of drop+add. - **Type:** `string` - **Required:** no - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` ### `entities..fields..required` Whether the field must be present (NOT NULL). - **Type:** `boolean` - **Required:** no - **Default:** `false` ### `entities..fields..unique` Whether values must be unique across the entity. - **Type:** `boolean` - **Required:** no - **Default:** `false` ### `entities..fields..nullable` Explicitly allow NULL; the default is derived from required. - **Type:** `boolean` - **Required:** no ### `entities..fields..default` Default value: a JSON literal, or a tagged expression {"$cel": "..."} (e.g. {"$cel": "now()"}, {"$cel": "gen\_random\_uuid()"}, {"$cel": "@user.id"}). The $cel default context includes @user/@tenant and is evaluated at insert time. - **Type:** `any` - **Required:** no - Not allowed together with `computed`. - Not allowed together with `rollup`. ### `entities..fields..default.$cel` A CEL expression whose result becomes the value, in place of a JSON literal (e.g. now(), @user.id). - **Type:** `string` - **Required:** yes ### `entities..fields..maxLength` type=string only. Counted in Unicode code points — the unit JSON Schema's own maxLength keyword and PostgreSQL's varchar(n) both use — not UTF-16 code units, so a character outside the Basic Multilingual Plane counts once. Both shipped drivers honour that bound; a dialect whose column counts UTF-16 units owes its own answer. - **Type:** `integer` - **Required:** no - Allowed only when `type` is `"string"`. ### `entities..fields..precision` type=decimal only (required). - **Type:** `integer` - **Required:** no - Required when `type` is `"decimal"`. - Allowed only when `type` is `"decimal"`. ### `entities..fields..scale` type=decimal only (required). - **Type:** `integer` - **Required:** no - Required when `type` is `"decimal"`. - Allowed only when `type` is `"decimal"`. ### `entities..fields..values` type=enum only. - **Type:** `array of string` - **Required:** no - Required when `type` is `"enum"`. - Allowed only when `type` is `"enum"`. ### `entities..fields..entity` type=ref only — target entity (FK to its id). May target the reserved `users` entity, or a dynamic/runtime entity (late-bound). - **Type:** `string` - **Required:** no - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` - Required when `type` is `"ref"`. - Allowed only when `type` is `"ref"`. ### `entities..fields..onDelete` type=ref only. Registry-level semantics: enforced as a DB FK for physical targets, app-enforced for dynamic targets. - **Type:** `string` - **Required:** no - **Values:** `"restrict"`, `"cascade"`, `"setNull"` - **Default:** `"restrict"` - Allowed only when `type` is `"ref"`. ### `entities..fields..format` type=string only — validation format: a built-in (email, uri, phone) or the name of a format declared in the top-level `formats`. An unknown name is caught fail-fast at apply (like a ref to a missing entity), not by this schema. - **Type:** `string` - **Required:** no - Allowed only when `type` is `"string"`. ### `entities..fields..validation` Optional CEL value validation (context: value, new). - **Type:** `string` - **Required:** no ### `entities..fields..index` Create an index (for dynamic entities = generated column + index over the JSON path). - **Type:** `boolean` - **Required:** no - **Default:** `false` ### `entities..fields..hidden` Field is never returned in API responses. true = always hidden; a CEL expression over @user makes visibility conditional (per-role masking, e.g. "!('compliance' in @user.roles)"). - **Type:** `boolean or string` - **Required:** no ### `entities..fields..readOnly` Field cannot be modified via the API. true = always read-only; a CEL expression over @user makes it conditional. - **Type:** `boolean or string` - **Required:** no --- # entities.fields: computed and rollup Source: https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/ :::danger[Refused at apply] `rollup.where` — A rollup's 'where' filter is not evaluated yet: the aggregate is still maintained, but it aggregates every record of the child entity instead of the subset this filter declares — a stored number that is silently wrong rather than absent. **Fix:** Remove 'where' and aggregate every child record, or move the distinction into the model: a separate child entity, or a second rollup once filtered rollups land. A partial implementation is deliberately not offered — an aggregate over the wrong row set costs more than this refusal. ::: **Guide:** [Computed fields and rollups](https://alvo.burgyn.online/guides/computed-and-rollups/) The `entities` block spans these pages: [entities](https://alvo.burgyn.online/reference/descriptor/entities/) · [entities · fields](https://alvo.burgyn.online/reference/descriptor/entities-fields/) · **entities · computed & rollups** · [entities · rules](https://alvo.burgyn.online/reference/descriptor/entities-rules/) · [entities · hooks](https://alvo.burgyn.online/reference/descriptor/entities-hooks/) · [entities · indexes](https://alvo.burgyn.online/reference/descriptor/entities-indexes/). ## Keys ### `entities..fields..computed` Value derived from other fields of the SAME row (pure arithmetic/expression, deterministic, no side effects), e.g. unit\_price \* amount. May also reference this entity's rollup fields (the framework maintains the value). Field becomes read-only. - **Type:** `string` - **Required:** no - Not allowed together with `rollup`. ### `entities..fields..rollup` Value aggregated over RELATED records, transactionally consistent (e.g. sum of child line\_total). Framework maintains it; the field is read-only. - **Type:** `object` - **Required:** no - Not allowed together with `computed`. ### `entities..fields..rollup.from` Child entity that references this one. - **Type:** `string` - **Required:** yes - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` ### `entities..fields..rollup.op` Aggregate operation over the child records. - **Type:** `string` - **Required:** yes - **Values:** `"sum"`, `"count"`, `"avg"`, `"min"`, `"max"` ### `entities..fields..rollup.field` Field on the child entity to aggregate (required for all ops except count). - **Type:** `string` - **Required:** no - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` - Required unless `op` is `"count"`. ### `entities..fields..rollup.via` The FK field on the child entity that points back to this parent. Required when the child has more than one ref to this parent (e.g. follows.follower vs follows.followee). - **Type:** `string` - **Required:** no - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` ### `entities..fields..rollup.where` Optional CEL filter on child records. - **Type:** `string` - **Required:** no --- # entities.rules Source: https://alvo.burgyn.online/reference/descriptor/entities-rules/ **Guide:** [Access rules](https://alvo.burgyn.online/guides/access-rules/) The `entities` block spans these pages: [entities](https://alvo.burgyn.online/reference/descriptor/entities/) · [entities · fields](https://alvo.burgyn.online/reference/descriptor/entities-fields/) · [entities · computed & rollups](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/) · **entities · rules** · [entities · hooks](https://alvo.burgyn.online/reference/descriptor/entities-hooks/) · [entities · indexes](https://alvo.burgyn.online/reference/descriptor/entities-indexes/). ## Keys ### `entities..rules` Per-operation authorization rules — CEL, compiled into a SQL predicate. Missing operation = deny (secure-by-default). - **Type:** `object` - **Required:** no ### `entities..rules.list` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no ### `entities..rules.get` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no ### `entities..rules.create` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no ### `entities..rules.update` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no ### `entities..rules.delete` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no --- # entities.hooks Source: https://alvo.burgyn.online/reference/descriptor/entities-hooks/ :::danger[Refused at apply] `JSONata` — JSONata transformations are not evaluated yet: the action still runs, but with Alvo's canonical event envelope as its body instead of the transformation declared here — a delivery that succeeded carrying data you did not declare, which is indistinguishable from a bug in the consumer. **Fix:** Use a '{{...}}' template instead (e.g. "{{new.title}}"), which this build does render, or remove the transformation and accept the canonical envelope. A partial JSONata implementation is deliberately not offered: silently producing a different payload for the part it does not implement costs more than this refusal. Tracked in #149. `email.data` — An 'email' action's 'data' is not rendered: it is validated when the descriptor is applied and then read by nothing, so the mail goes out with the referenced template's own subject and body and the values declared here are silently dropped — a message that was delivered without the data you declared, which is indistinguishable from a template bug. **Fix:** Move the values into the template's 'subject'/'body' as '{{...}}' placeholders over 'new'/'old'/'event'/'@user.id', which this build does render, or remove 'data'. A 'data.\*' placeholder root is new surface and lands with the PR that reads it. `function` — The 'function' action is declared in the schema but not implemented in this build, so this hook invokes nothing — no function declared under 'functions' runs, on this hook or on any trigger or schedule it declares elsewhere. **Fix:** Use a 'webhook' or 'email' action, which this build does run. Custom functions are planned and the schema freezes their shape ahead of the implementation. `http.call` — The 'http.call' action is declared in the schema but not implemented in this build, so this hook makes no request: the URL is never called, and 'headersSecretRef' is never read, so a receiver you believe is being notified is not. **Fix:** Declare the target under 'webhooks.endpoints' and use a 'webhook' action instead — that is the managed path, and it is the one this build delivers on. `entity.update` — The 'entity.update' action is declared in the schema but not implemented in this build, so this hook writes nothing — no record is written or patched on the target entity, and no event is emitted for the write that did not happen. **Fix:** Perform the follow-up write through the Data API for now. 'entity.update' lands with automation, where the causation chain it creates can be bounded. ::: **Guide:** [Before-hooks](https://alvo.burgyn.online/guides/before-hooks/) The `entities` block spans these pages: [entities](https://alvo.burgyn.online/reference/descriptor/entities/) · [entities · fields](https://alvo.burgyn.online/reference/descriptor/entities-fields/) · [entities · computed & rollups](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/) · [entities · rules](https://alvo.burgyn.online/reference/descriptor/entities-rules/) · **entities · hooks** · [entities · indexes](https://alvo.burgyn.online/reference/descriptor/entities-indexes/). ## Keys ### `entities..hooks` Lifecycle hooks. before\* = in-transaction, no network, may reject/mutate; after\* = post-commit from the outbox. - **Type:** `object` - **Required:** no ### `entities..hooks.beforeCreate` Before-hooks, run in declaration order inside the write's transaction: each may `reject` the write or `mutate` the row about to be written (`mutate` is refused on `beforeDelete`). No network access. - **Type:** `array of object` - **Required:** no ### `entities..hooks.beforeCreate[].condition` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no ### `entities..hooks.beforeCreate[].action` Before-actions run in-transaction: reject or mutate only. No network, no external calls. - **Type:** `object` - **Required:** yes ### `entities..hooks.beforeCreate[].action.reject` Cancels the operation; the text becomes the detail of the RFC 7807 error. - **Type:** `string` - **Required:** yes (in its variant) - Exactly one of `reject`, `mutate`. ### `entities..hooks.beforeCreate[].action.mutate` Payload patch before write: field -> literal value or tagged {"$cel": "..."} expression. - **Type:** `map of any` - **Required:** yes (in its variant) - Exactly one of `reject`, `mutate`. - Each entry: A literal JSON value, or a tagged CEL expression object {"$cel": "..."}. ### `entities..hooks.beforeCreate[].action.mutate..$cel` A CEL expression whose result becomes the value, in place of a JSON literal (e.g. now(), @user.id). - **Type:** `string` - **Required:** yes ### `entities..hooks.beforeUpdate` Before-hooks, run in declaration order inside the write's transaction: each may `reject` the write or `mutate` the row about to be written (`mutate` is refused on `beforeDelete`). No network access. - **Type:** `array of object` - **Required:** no - Same shape as [`entities..hooks.beforeCreate`](#entities.hooks.beforeCreate). ### `entities..hooks.beforeDelete` Before-hooks, run in declaration order inside the write's transaction: each may `reject` the write or `mutate` the row about to be written (`mutate` is refused on `beforeDelete`). No network access. - **Type:** `array of object` - **Required:** no - Same shape as [`entities..hooks.beforeCreate`](#entities.hooks.beforeCreate). ### `entities..hooks.afterCreate` After-hooks, delivered post-commit from the outbox with retries; a `condition` filters which writes trigger them. - **Type:** `array of object` - **Required:** no ### `entities..hooks.afterCreate[].condition` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no ### `entities..hooks.afterCreate[].action` After-side action (post-commit, durable, retries). Payload transformations = JSONata; {{...}} is sugar. - **Type:** `object` - **Required:** yes ### `entities..hooks.afterCreate[].action.type` Selects the variant. - **Type:** `string` - **Required:** yes - **Values:** `"webhook"`, `"email"`, `"function"`, `"entity.update"`, `"http.call"` ### `entities..hooks.afterCreate[].action.endpoint` The name of an endpoint declared under the top-level `webhooks.endpoints`. - **Type:** `string` - **Required:** yes (in its variant) - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` - Only when `type` is `"webhook"`. ### `entities..hooks.afterCreate[].action.payload` Depends on the variant; see the notes below. - **Type:** `string or map of any` - **Required:** depends on the variant - Only when `type` is `"webhook"`, `"entity.update"` or `"http.call"`. - When `type` is `"webhook"` or `"http.call"`: `string`, optional — JSONata transformation expression (JSON -> JSON). Runs strictly post-commit (after-side), with depth/time limits. {{...}} templates are syntactic sugar. - When `type` is `"entity.update"`: `map of any`, required — Field -> literal value or tagged {"$cel": "..."} expression; {{...}} sugar allowed in string values. Each entry: A literal JSON value, or a tagged CEL expression object {"$cel": "..."}. - When `type` is `"entity.update"`: Same shape as [`entities..hooks.beforeCreate[].action.mutate.`](#entities.hooks.beforeCreate.action.mutate). ### `entities..hooks.afterCreate[].action.template` The name of a template declared in the top-level `templates` block. - **Type:** `string` - **Required:** yes (in its variant) - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` - Only when `type` is `"email"`. ### `entities..hooks.afterCreate[].action.to` Address or a {{...}} template. - **Type:** `string` - **Required:** yes (in its variant) - Only when `type` is `"email"`. ### `entities..hooks.afterCreate[].action.data` JSONata transformation expression (JSON -> JSON). Runs strictly post-commit (after-side), with depth/time limits. {{...}} templates are syntactic sugar. - **Type:** `string` - **Required:** no - Only when `type` is `"email"`. ### `entities..hooks.afterCreate[].action.name` The name of a function declared in the top-level `functions` block. - **Type:** `string` - **Required:** yes (in its variant) - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` - Only when `type` is `"function"`. ### `entities..hooks.afterCreate[].action.input` JSONata transformation expression (JSON -> JSON). Runs strictly post-commit (after-side), with depth/time limits. {{...}} templates are syntactic sugar. - **Type:** `string` - **Required:** no - Only when `type` is `"function"`. ### `entities..hooks.afterCreate[].action.entity` The entity whose record is created or updated. - **Type:** `string` - **Required:** yes (in its variant) - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` - Only when `type` is `"entity.update"`. ### `entities..hooks.afterCreate[].action.recordId` Id or {{...}} template; omit to create a new record. - **Type:** `string` - **Required:** no - Only when `type` is `"entity.update"`. ### `entities..hooks.afterCreate[].action.url` Absolute URL to call. - **Type:** `string` - **Required:** yes (in its variant) - Only when `type` is `"http.call"`. ### `entities..hooks.afterCreate[].action.method` HTTP method. - **Type:** `string` - **Required:** no - **Values:** `"GET"`, `"POST"`, `"PUT"`, `"PATCH"`, `"DELETE"` - **Default:** `"POST"` - Only when `type` is `"http.call"`. ### `entities..hooks.afterCreate[].action.headersSecretRef` Secret name in ISecretStore holding request headers (e.g. auth token); never the value itself. - **Type:** `string` - **Required:** no - Only when `type` is `"http.call"`. ### `entities..hooks.afterUpdate` After-hooks, delivered post-commit from the outbox with retries; a `condition` filters which writes trigger them. - **Type:** `array of object` - **Required:** no - Same shape as [`entities..hooks.afterCreate`](#entities.hooks.afterCreate). ### `entities..hooks.afterDelete` After-hooks, delivered post-commit from the outbox with retries; a `condition` filters which writes trigger them. - **Type:** `array of object` - **Required:** no - Same shape as [`entities..hooks.afterCreate`](#entities.hooks.afterCreate). --- # entities.indexes Source: https://alvo.burgyn.online/reference/descriptor/entities-indexes/ **Guide:** [Indexes and uniqueness](https://alvo.burgyn.online/guides/indexes/) The `entities` block spans these pages: [entities](https://alvo.burgyn.online/reference/descriptor/entities/) · [entities · fields](https://alvo.burgyn.online/reference/descriptor/entities-fields/) · [entities · computed & rollups](https://alvo.burgyn.online/reference/descriptor/entities-computed-and-rollups/) · [entities · rules](https://alvo.burgyn.online/reference/descriptor/entities-rules/) · [entities · hooks](https://alvo.burgyn.online/reference/descriptor/entities-hooks/) · **entities · indexes**. ## Keys ### `entities..indexes` Explicit composite/extra indexes beyond the automatic ones (PK, unique, ref). - **Type:** `array of object` - **Required:** no ### `entities..indexes[].fields` Fields covered by the index, in order. - **Type:** `array of string` - **Required:** yes ### `entities..indexes[].unique` Whether the index enforces uniqueness across the covered fields. - **Type:** `boolean` - **Required:** no - **Default:** `false` --- # automation Source: https://alvo.burgyn.online/reference/descriptor/automation/ :::caution[Not run in this build] no rule is ever evaluated, so no declared action runs — which looks exactly like a condition that never matched ::: :::danger[Refused at apply] `trigger.event` — A wildcard event subscription is not matched yet: no rule fires for it today, and on the build that does implement matching it would subscribe to every entity or every operation of every tenant at once — a cross-tenant fan-out nobody re-reads this descriptor to catch, because the event envelope carries no tenant for a subscription to be scoped by (#153). **Fix:** Name the entity and the operation exactly — 'entity.orders.created' rather than 'entity.orders.\*' — and declare one rule per pair. Wildcards are refused rather than accepted-and-ignored because a descriptor outlives the build that applied it: accepted today, it becomes the fan-out above on the day matching lands, with nothing between it and delivery. ::: **Not in this build:** see [What works today](https://alvo.burgyn.online/start-here/what-works-today/). ### `automation` ECA rules (event–condition–action). Key = rule name. Run post-commit from the outbox, durable, with retries. - **Type:** `map of object` - **Required:** no - Names match `^[a-z][a-z0-9_-]{0,62}$`. ## Keys ### `automation..description` Human-readable description of the automation rule. - **Type:** `string` - **Required:** no ### `automation..enabled` Whether the rule is active. - **Type:** `boolean` - **Required:** no - **Default:** `true` ### `automation..trigger` What fires the rule: an event pattern or a cron schedule. - **Type:** `object` - **Required:** yes ### `automation..trigger.event` Event pattern, e.g. entity.deals.updated, entity.\*.created, entity.orders.\* or the coalesced batch shape entity.orders.created.batch. - **Type:** `string` - **Required:** yes (in its variant) - **Pattern:** `^(entity|auth|storage)\.([a-z][a-z0-9_]*|\*)\.([a-z]+|\*)(\.batch)?$` - Exactly one of `event`, `schedule`. ### `automation..trigger.schedule` Cron expression (5 fields, UTC). - **Type:** `string` - **Required:** yes (in its variant) - **Pattern:** `^\S+ \S+ \S+ \S+ \S+$` - Exactly one of `event`, `schedule`. ### `automation..condition` Condition in the CEL subset. Context: @user, @tenant; hooks/events additionally expose old, new, changed(field). Compiled fail-fast at apply time. - **Type:** `string` - **Required:** no ### `automation..delivery` perItem = one execution per affected row; batch = one execution with an array payload (coalesces bulk operations; matches the entity.\*.\*.batch event shape). - **Type:** `string` - **Required:** no - **Values:** `"perItem"`, `"batch"` - **Default:** `"perItem"` ### `automation..actions` Actions to run when the rule fires (in order). - **Type:** `array of object` - **Required:** yes ### `automation..actions[].type` Selects the variant. - **Type:** `string` - **Required:** yes - **Values:** `"webhook"`, `"email"`, `"function"`, `"entity.update"`, `"http.call"` ### `automation..actions[].endpoint` The name of an endpoint declared under the top-level `webhooks.endpoints`. - **Type:** `string` - **Required:** yes (in its variant) - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` - Only when `type` is `"webhook"`. ### `automation..actions[].payload` Depends on the variant; see the notes below. - **Type:** `string or map of any` - **Required:** depends on the variant - Only when `type` is `"webhook"`, `"entity.update"` or `"http.call"`. - When `type` is `"webhook"` or `"http.call"`: `string`, optional — JSONata transformation expression (JSON -> JSON). Runs strictly post-commit (after-side), with depth/time limits. {{...}} templates are syntactic sugar. - When `type` is `"entity.update"`: `map of any`, required — Field -> literal value or tagged {"$cel": "..."} expression; {{...}} sugar allowed in string values. Each entry: A literal JSON value, or a tagged CEL expression object {"$cel": "..."}. ### `automation..actions[].payload..$cel` A CEL expression whose result becomes the value, in place of a JSON literal (e.g. now(), @user.id). - **Type:** `string` - **Required:** yes ### `automation..actions[].template` The name of a template declared in the top-level `templates` block. - **Type:** `string` - **Required:** yes (in its variant) - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` - Only when `type` is `"email"`. ### `automation..actions[].to` Address or a {{...}} template. - **Type:** `string` - **Required:** yes (in its variant) - Only when `type` is `"email"`. ### `automation..actions[].data` JSONata transformation expression (JSON -> JSON). Runs strictly post-commit (after-side), with depth/time limits. {{...}} templates are syntactic sugar. - **Type:** `string` - **Required:** no - Only when `type` is `"email"`. ### `automation..actions[].name` The name of a function declared in the top-level `functions` block. - **Type:** `string` - **Required:** yes (in its variant) - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` - Only when `type` is `"function"`. ### `automation..actions[].input` JSONata transformation expression (JSON -> JSON). Runs strictly post-commit (after-side), with depth/time limits. {{...}} templates are syntactic sugar. - **Type:** `string` - **Required:** no - Only when `type` is `"function"`. ### `automation..actions[].entity` The entity whose record is created or updated. - **Type:** `string` - **Required:** yes (in its variant) - **Pattern:** `^[a-z][a-z0-9_-]{0,62}$` - Only when `type` is `"entity.update"`. ### `automation..actions[].recordId` Id or {{...}} template; omit to create a new record. - **Type:** `string` - **Required:** no - Only when `type` is `"entity.update"`. ### `automation..actions[].url` Absolute URL to call. - **Type:** `string` - **Required:** yes (in its variant) - Only when `type` is `"http.call"`. ### `automation..actions[].method` HTTP method. - **Type:** `string` - **Required:** no - **Values:** `"GET"`, `"POST"`, `"PUT"`, `"PATCH"`, `"DELETE"` - **Default:** `"POST"` - Only when `type` is `"http.call"`. ### `automation..actions[].headersSecretRef` Secret name in ISecretStore holding request headers (e.g. auth token); never the value itself. - **Type:** `string` - **Required:** no - Only when `type` is `"http.call"`. --- # templates Source: https://alvo.burgyn.online/reference/descriptor/templates/ :::caution[Not run in this build] a template referenced by an after-hook 'email' action is rendered, but one referenced only from an automation rule is not, because no rule is evaluated yet — and a 'bodyFile' is not read on either path ::: :::danger[Refused at apply] `bodyFile` — A template's 'bodyFile' is not read yet: nothing in this build resolves a path inside a descriptor bundle, so an after-hook rendering this template would send a message with an empty body rather than fail. **Fix:** Move the body inline into the template's 'body' — it takes the same '{{...}}' placeholders — or stop referencing this template from an after-hook until bundle files are read. ::: **Guide:** [After-hooks, events and webhooks](https://alvo.burgyn.online/guides/after-hooks-and-webhooks/) ### `templates` Reusable message templates referenced by email/notification actions. Key = template name. - **Type:** `map of object` - **Required:** no - Each entry: A reusable message template. Provide an inline body or a bundle-relative bodyFile. - Names match `^[a-z][a-z0-9_-]{0,62}$`. ## Keys ### `templates..subject` Subject line; supports {{...}} interpolation. - **Type:** `string` - **Required:** no ### `templates..body` Inline body; supports {{...}} interpolation. - **Type:** `string` - **Required:** no ### `templates..bodyFile` Relative path to a template file inside the descriptor bundle. - **Type:** `string` - **Required:** no --- # formats Source: https://alvo.burgyn.online/reference/descriptor/formats/ **Guide:** [Entities and fields](https://alvo.burgyn.online/guides/entities-and-fields/) ### `formats` Reusable named validation formats referenced by field.format (beyond the built-ins email/uri/phone). Declarative and engine-agnostic (works in standalone), enforced by Alvo at the API layer and reflected into the generated OpenAPI as a pattern. For validators a regex cannot express (checksums, external lookups), a host registers an IFieldFormatValidator in embedded mode instead. Key = format name. - **Type:** `map of object` - **Required:** no - Each entry: A reusable validation format defined by a regular expression. - Names match `^[a-z][a-z0-9_-]{0,62}$`. ## Keys ### `formats..pattern` Regular expression the field value must match. - **Type:** `string` - **Required:** yes ### `formats..description` Human-readable description of the format (surfaced to agents and in the generated OpenAPI). - **Type:** `string` - **Required:** no --- # webhooks Source: https://alvo.burgyn.online/reference/descriptor/webhooks/ :::caution[Not run in this build] an endpoint an after-hook posts to is delivered to, but one referenced only from an automation rule never receives anything; and no delivery is signed — 'secretRef' is not read and no Standard Webhooks HMAC header is sent, so a receiver cannot yet verify the sender (7.1), nor is the payload projected per endpoint (#152) ::: **Guide:** [After-hooks, events and webhooks](https://alvo.burgyn.online/guides/after-hooks-and-webhooks/) ### `webhooks` Managed webhook endpoints (Standard Webhooks: HMAC signing, retries, DLQ). - **Type:** `object` - **Required:** no ## Keys ### `webhooks.endpoints` Managed webhook endpoints, keyed by endpoint name. - **Type:** `map of object` - **Required:** no - Names match `^[a-z][a-z0-9-]{0,62}$`. ### `webhooks.endpoints..url` HTTPS target. - **Type:** `string` - **Required:** yes ### `webhooks.endpoints..secretRef` Secret name in ISecretStore (never the secret value itself). - **Type:** `string` - **Required:** yes ### `webhooks.endpoints..description` Human-readable description of the endpoint. - **Type:** `string` - **Required:** no --- # functions Source: https://alvo.burgyn.online/reference/descriptor/functions/ :::caution[Not run in this build] no function is ever invoked, on any trigger or schedule it declares ::: **Not in this build:** see [What works today](https://alvo.burgyn.online/start-here/what-works-today/). ### `functions` Custom logic — csx scripts (standalone). Key = function name. Trust: admin-level code. - **Type:** `map of object` - **Required:** no - Names match `^[a-z][a-z0-9-]{0,62}$`. ## Keys ### `functions..script` Relative path to a .csx file inside the descriptor bundle. - **Type:** `string` - **Required:** yes - **Pattern:** `\.csx$` ### `functions..trigger` Optional trigger; a function without a trigger is only invocable from the `function` automation action. - **Type:** `object` - **Required:** no ### `functions..trigger.http` Expose the function at an HTTP route (also serves as an inbound receiver). - **Type:** `object` - **Required:** yes (in its variant) - Exactly one of `http`, `schedule`, `event`. ### `functions..trigger.http.route` Route path, starting with '/'. - **Type:** `string` - **Required:** yes - **Pattern:** `^/` ### `functions..trigger.http.method` HTTP method (defaults to POST when omitted). - **Type:** `string` - **Required:** no - **Values:** `"GET"`, `"POST"`, `"PUT"`, `"PATCH"`, `"DELETE"` ### `functions..trigger.schedule` Cron expression (5 fields, UTC). - **Type:** `string` - **Required:** yes (in its variant) - **Pattern:** `^\S+ \S+ \S+ \S+ \S+$` - Exactly one of `http`, `schedule`, `event`. ### `functions..trigger.event` Event pattern, e.g. entity.deals.updated, entity.\*.created, entity.orders.\* or the coalesced batch shape entity.orders.created.batch. - **Type:** `string` - **Required:** yes (in its variant) - **Pattern:** `^(entity|auth|storage)\.([a-z][a-z0-9_]*|\*)\.([a-z]+|\*)(\.batch)?$` - Exactly one of `http`, `schedule`, `event`. ### `functions..execution` Sync = in the request path (short timeout); queued = via outbox and worker (default). - **Type:** `string` - **Required:** no - **Values:** `"sync"`, `"queued"` - **Default:** `"queued"` --- # CEL functions Source: https://alvo.burgyn.online/reference/cel-functions/ This page is generated from `GET {management}/projects/{project}/cel/functions` on a host with no `AddCelFunction` registrations, so it lists exactly the functions every Alvo host knows. A name with several overloads has one row per overload. A `?` after a parameter type means the function receives a null argument; without it, a null argument makes the whole call null and the function is not invoked. A `?` after the return type means the call may yield null even when every argument is present. **Profiles** are the descriptor slots a call compiles in: `Condition` (a hook `condition`) and `Mutate` (a before-hook `mutate` value). Rules, computed fields and access levels admit no function call. See [CEL in Alvo](https://alvo.burgyn.online/concepts/cel/) for the profiles and the language subset, and [Custom CEL functions](https://alvo.burgyn.online/guides/custom-cel-functions/) to register your own. | Function | Signature | Returns | Profiles | Summary | |---|---|---|---|---| | `contains` | `contains(text: String, search: String)` | `Bool` | Condition, Mutate | Whether text holds search anywhere, comparing characters exactly; an empty search is always held. | | `endsWith` | `endsWith(text: String, suffix: String)` | `Bool` | Condition, Mutate | Whether text ends with suffix, comparing characters exactly; every text ends with an empty suffix. | | `int` | `int(value: Decimal)` | `Int` | Condition, Mutate | The value as a whole number: a decimal cut toward zero (2.9 is 2), or a text of digits with an optional sign; anything else fails the write. | | `int` | `int(value: String)` | `Int` | Condition, Mutate | The value as a whole number: a decimal cut toward zero (2.9 is 2), or a text of digits with an optional sign; anything else fails the write. | | `lowerAscii` | `lowerAscii(text: String)` | `String` | Condition, Mutate | Folds A-Z to a-z and changes nothing else: accented and other non-ASCII letters stay as they are. | | `math.abs` | `math.abs(x: Int)` | `Int` | Condition, Mutate | The absolute value of x, of the same numeric type. | | `math.abs` | `math.abs(x: Decimal)` | `Decimal` | Condition, Mutate | The absolute value of x, of the same numeric type. | | `math.ceil` | `math.ceil(x: Int)` | `Int` | Condition, Mutate | The smallest whole number not below x (1.2 is 2, -1.5 is -1), of the same numeric type. | | `math.ceil` | `math.ceil(x: Decimal)` | `Decimal` | Condition, Mutate | The smallest whole number not below x (1.2 is 2, -1.5 is -1), of the same numeric type. | | `math.floor` | `math.floor(x: Int)` | `Int` | Condition, Mutate | The largest whole number not above x (1.8 is 1, -1.2 is -2), of the same numeric type. | | `math.floor` | `math.floor(x: Decimal)` | `Decimal` | Condition, Mutate | The largest whole number not above x (1.8 is 1, -1.2 is -2), of the same numeric type. | | `math.greatest` | `math.greatest(a: Int, b: Int)` | `Int` | Condition, Mutate | The larger of a and b; a when they are equal. | | `math.greatest` | `math.greatest(a: Decimal, b: Decimal)` | `Decimal` | Condition, Mutate | The larger of a and b; a when they are equal. | | `math.least` | `math.least(a: Int, b: Int)` | `Int` | Condition, Mutate | The smaller of a and b; a when they are equal. | | `math.least` | `math.least(a: Decimal, b: Decimal)` | `Decimal` | Condition, Mutate | The smaller of a and b; a when they are equal. | | `math.round` | `math.round(x: Int)` | `Int` | Condition, Mutate | Rounds x to a whole number, halves away from zero (2.5 is 3, -2.5 is -3), of the same numeric type. | | `math.round` | `math.round(x: Decimal)` | `Decimal` | Condition, Mutate | Rounds x to a whole number, halves away from zero (2.5 is 3, -2.5 is -3), of the same numeric type. | | `math.round` | `math.round(x: Decimal, digits: Int)` | `Decimal` | Condition, Mutate | Rounds x to digits places after the point, halves away from zero (2.345 to 2 places is 2.35); digits is from 0 to 28. | | `now` | `now()` | `Timestamp` | Mutate | The instant this write is stamped with — the same one its audit columns get, never a clock read. | | `replace` | `replace(text: String, search: String, replacement: String)` | `String` | Condition, Mutate | Replaces every occurrence of search in text with replacement, left to right, comparing characters exactly; an empty search changes nothing. | | `size` | `size(text: String)` | `Int` | Condition, Mutate | The number of Unicode code points in text. | | `startsWith` | `startsWith(text: String, prefix: String)` | `Bool` | Condition, Mutate | Whether text begins with prefix, comparing characters exactly; every text begins with an empty prefix. | | `string` | `string(value: Int)` | `String` | Condition, Mutate | The value as text: digits for a number (no trailing zeros), true or false, a lower-case id, or an RFC 3339 instant in UTC. | | `string` | `string(value: Decimal)` | `String` | Condition, Mutate | The value as text: digits for a number (no trailing zeros), true or false, a lower-case id, or an RFC 3339 instant in UTC. | | `string` | `string(value: Bool)` | `String` | Condition, Mutate | The value as text: digits for a number (no trailing zeros), true or false, a lower-case id, or an RFC 3339 instant in UTC. | | `string` | `string(value: Uuid)` | `String` | Condition, Mutate | The value as text: digits for a number (no trailing zeros), true or false, a lower-case id, or an RFC 3339 instant in UTC. | | `string` | `string(value: Timestamp)` | `String` | Condition, Mutate | The value as text: digits for a number (no trailing zeros), true or false, a lower-case id, or an RFC 3339 instant in UTC. | | `substring` | `substring(text: String, start: Int)` | `String` | Condition, Mutate | The code points of text from start (counted from 0) up to, not including, end — or to the end of text; a position outside text fails the write. | | `substring` | `substring(text: String, start: Int, end: Int)` | `String` | Condition, Mutate | The code points of text from start (counted from 0) up to, not including, end — or to the end of text; a position outside text fails the write. | | `timestamp` | `timestamp(text: String)` | `Timestamp` | Condition, Mutate | The text read as an RFC 3339 instant, such as 2026-10-05T12:00:00Z or 2026-10-05T14:00:00+02:00; anything else fails the write. | | `trim` | `trim(text: String)` | `String` | Condition, Mutate | Removes spaces, tabs, line feeds and carriage returns from both ends of text; nothing else counts as whitespace. | | `upperAscii` | `upperAscii(text: String)` | `String` | Condition, Mutate | Folds a-z to A-Z and changes nothing else: accented and other non-ASCII letters stay as they are. | --- # Configuration keys Source: https://alvo.burgyn.online/reference/configuration/ Configuration uses the standard .NET options pattern. In environment variables, replace `:` with `__` (`Alvo__Api__DefaultPageSize`). ## `Alvo` Standalone host (the `ghcr.io/burgyn/alvo` image). Options type: `AlvoHostOptions`. | Key | Type | Default | Description | |---|---|---|---| | `Alvo:DescriptorPath` | string | `/alvo/descriptor.json` | Gets or sets the project descriptor's path (default `/alvo/descriptor.json`, the image's mount point). | | `Alvo:Database:Provider` | string | `sqlite` | Gets or sets the driver to register — `Sqlite` or `PostgreSql`. | | `Alvo:Database:SqliteConnectionString` | string | `Data Source=/alvo/data/alvo.db` | Gets or sets the connection string used when `Provider` is `Sqlite` and `ConnectionStrings:Alvo` is not set. | | `Alvo:PathBase` | string? | — | Gets or sets the path base the host is served under, for a deployment behind a reverse proxy that does not rewrite (default none). | | `Alvo:ForwardedHeaders:Enabled` | bool | false | Gets or sets whether `X-Forwarded-For`, `-Proto`, `-Host` and `-Prefix` are honoured (default `false`). | | `Alvo:Docs:Enabled` | bool | true | Gets or sets whether the docs UI and the OpenAPI document are served (default `true`). | ## `Alvo:Admin` Bound by the standalone host; an embedded host configures it in code. Options type: `AlvoIdentityOptions`. | Key | Type | Default | Description | |---|---|---|---| | `Alvo:Admin:BootstrapEmail` | string? | — | Gets or sets the address of the bootstrap administrator, or `null` for none. | | `Alvo:Admin:BootstrapPasswordFile` | string? | — | Gets or sets the path of the file holding the bootstrap administrator's password. | ## `Alvo:Admin:Dashboard` Bound by the standalone host; an embedded host configures it in code. Options type: `AlvoAdminOptions`. | Key | Type | Default | Description | |---|---|---|---| | `Alvo:Admin:Dashboard:Enabled` | bool | true | Whether the dashboard is mapped at all. Defaults to `true`. | | `Alvo:Admin:Dashboard:DocsPath` | string? | — | Where this host serves its interactive API documentation, or `null` when it serves none. **The standalone host overwrites this** after binding: `/scalar` when `Alvo:Docs:Enabled` is true, otherwise none. Set it only in an embedded host. | | `Alvo:Admin:Dashboard:OpenApiPath` | string? | — | Where this host serves the OpenAPI document itself, or `null` when it serves none. **The standalone host overwrites this** after binding: `/openapi/v1.json` when `Alvo:Docs:Enabled` is true, otherwise none. Set it only in an embedded host. | ## `Alvo:Ai` Any host (bound by the core). Options type: `AlvoAiOptions`. | Key | Type | Default | Description | |---|---|---|---| | `Alvo:Ai:Kind` | string? | — | Gets or sets which protocol the endpoint speaks — `openai-compatible` or `azure-openai`. | | `Alvo:Ai:Endpoint` | string? | — | Gets or sets the base address to dial, e.g. `http://localhost:11434/v1`. | | `Alvo:Ai:Model` | string? | — | Gets or sets the model or deployment name to ask for. | | `Alvo:Ai:ApiKeySecretRef` | string? | — | Gets or sets the name of the secret holding the API key, resolved through the secret store. | ## `Alvo:Api` Bound by the standalone host; an embedded host configures it in code. Options type: `AlvoApiOptions`. | Key | Type | Default | Description | |---|---|---|---| | `Alvo:Api:RoutePrefix` | string | `/api` | The route prefix every generated endpoint sits under. Default `/api`. | | `Alvo:Api:DefaultPageSize` | int | 50 | The page size used when a request names none. Default 50. | | `Alvo:Api:MaxPageSize` | int | 200 | The largest page a request may ask for. Default 200. Server-enforced rather than advisory: a maximum is required, because an unbounded limit is a denial-of-service one query long. | | `Alvo:Api:MaxRequestBodyBytes` | int | 1048576 | The largest request body a write endpoint will read. Default 1 MiB. | | `Alvo:Api:MaxPayloadDepth` | int | 32 | How deeply a request body may nest. Default 32. | | `Alvo:Api:MaxPayloadKeys` | int | 512 | How many property names a request body may carry *in total, at any depth*. Default 512. | | `Alvo:Api:MaxBatchRows` | int | 1000 | The most rows one batch request may carry. Default 1000. | | `Alvo:Api:MaxIdempotencyKeyBytes` | int | 255 | The longest `Idempotency-Key` a create will accept, in **UTF-8 bytes**. Defaults to `MaxKeyBytes`, and may only be lowered. | ## `Alvo:Auth` Bound by the standalone host; an embedded host configures it in code. Options type: `AlvoAuthOptions`. | Key | Type | Default | Description | |---|---|---|---| | `Alvo:Auth:DevKeys:{n}:KeyId` | string | `""` | Gets or sets the key's public identifier. | | `Alvo:Auth:DevKeys:{n}:Secret` | string | `""` | Gets or sets the plaintext secret as configured; retained on the options instance for the process lifetime — a dev mechanism only, not a production issuance path. | | `Alvo:Auth:DevKeys:{n}:User` | Guid | 00000000-0000-0000-0000-000000000000 | Gets or sets the user this key authenticates as. | | `Alvo:Auth:DevKeys:{n}:Roles:{n}` | string | empty | Gets the names of the roles this key grants. | | `Alvo:Auth:DevKeys:{n}:Tenant` | Guid? | — | Gets or sets the tenant this key is scoped to, if any. | | `Alvo:Auth:DevKeys:{n}:Scopes:{n}` | string | empty | Gets the entity/access scopes this key grants, in the descriptor form `":"`. | | `Alvo:Auth:DevKeys:{n}:ExpiresAt` | DateTimeOffset? | — | Gets or sets when this key expires, if ever. | | `Alvo:Auth:HeaderName` | string | `X-Alvo-Api-Key` | Gets the HTTP header a presented API key is read from, consumed by the HTTP Data API. | | `Alvo:Auth:TenantHeaderName` | string | `X-Alvo-Tenant` | Gets the HTTP header the tenant a caller asks to act in is read from, consumed by the HTTP Data API. | ## `Alvo:Events` Any host (bound by the core). Options type: `AlvoEventOptions`. | Key | Type | Default | Description | |---|---|---|---| | `Alvo:Events:Enabled` | bool | true | Gets or sets whether this process drains the outbox. Defaults to `true`. | | `Alvo:Events:PollInterval` | TimeSpan | 00:00:01 | Gets or sets how long the pump waits after finding nothing to claim, before claiming again. Defaults to one second, and must be greater than zero. | | `Alvo:Events:BatchSize` | int | 100 | Gets or sets the most entries one claim takes. Defaults to 100, and must be at least 1. | | `Alvo:Events:MaxAttempts` | int | 10 | Gets or sets how many times one event may be claimed before it is left alone. Defaults to 10, and must be at least 1. | | `Alvo:Events:ClaimLease` | TimeSpan | 00:05:00 | Gets or sets how long a claim holds before another claimant may take the entry back. Defaults to five minutes, and must be longer than `PollInterval`. | | `Alvo:Events:WebhookAllowedNetworks:{n}` | string | empty | Gets the non-public networks, in CIDR notation, a webhook may be delivered to. Empty by default, which allows only globally reachable addresses — and loopback, when the endpoint names it literally. | ## `Alvo:Management` Any host (bound by the core). Options type: `AlvoManagementOptions`. | Key | Type | Default | Description | |---|---|---|---| | `Alvo:Management:RoutePrefix` | string | `/management` | The route prefix every management endpoint sits under. Default `/management`. | ## `Alvo:Schema` Any host (bound by the core). Options type: `AlvoSchemaOptions`. | Key | Type | Default | Description | |---|---|---|---| | `Alvo:Schema:Startup` | one of: Verify, Apply, Skip | Apply | Gets or sets what a boot does when the descriptor has drifted from the applied schema. Defaults to `Apply`, which brings the database up to the descriptor and still refuses any step that would discard data. | | `Alvo:Schema:Project` | string? | — | Gets or sets the project a **dashboard-first** host boots — the project whose stored descriptor the boot reads when no `IDescriptorSource` is configured. `null` in code-first mode, where the descriptor names the project itself. | | `Alvo:Schema:AllowDestructive` | bool | false | Gets or sets whether a boot may apply a plan that drops or narrows something — the guardrail that separates `Apply` from data loss. Defaults to `false`, so a destructive plan is refused even under `Apply`. | ## `Alvo:Secrets` Any host (bound by the core). Options type: `AlvoSecretOptions`. | Key | Type | Default | Description | |---|---|---|---| | `Alvo:Secrets:EncryptionKeyFile` | string? | — | Gets or sets the path to the file holding the key the database-backed store encrypts with — 32 bytes, base64. | | `Alvo:Secrets:Values:{name}` | string | empty | Gets or sets the secrets this deployment supplies through configuration itself, by name. | ## Not bound from configuration These public options types are not read from any configuration section: - `AlvoOptions` — Configured in code with `Configure`; no host binds it from configuration. - `MigrationOptions` — A per-call argument to the schema migrator, not a configuration section; the `Alvo:Schema` keys decide what start-up passes it. - `PostgreSqlProviderOptions` — Set in code by `UsePostgreSql(…)`; its connection string comes from `ConnectionStrings:Alvo`, listed below. - `SqliteProviderOptions` — Set in code by `UseSqlite(…)`; its connection string comes from `ConnectionStrings:Alvo`, listed below. ## Keys read outside an options type These keys are read directly rather than through an options type. | Key | Type | Default | Description | |---|---|---|---| | `ConnectionStrings:Alvo` | string | — | The database connection string. The parameterless `UseSqlite()` and `UsePostgreSql()` read it; the standalone host reads it for either driver and, for SQLite only, falls back to `Alvo:Database:SqliteConnectionString` when it is unset. | | `Alvo:Admin:CredentialAttemptsPerMinute` | int | 20 | Standalone host: sign-in and set-password attempts per minute for one subject (an address, or a set-password token) from one client. Must be positive while the dashboard is on. | | `Alvo:Admin:CredentialCeilingPerMinute` | int | 200 | Standalone host: the ceiling per client per minute, shared by both credential forms, over the per-subject budget. Must be positive while the dashboard is on. | | `Alvo:Admin:SessionRevalidationSeconds` | int | 30 | A test seam, not a setting: how often an open dashboard tab's session is re-checked. It can only shorten the interval; anything but a whole number from 1 to 30 is refused at start. | --- # Problem types Source: https://alvo.burgyn.online/reference/problem-types/ Every refusal is an RFC 9457 problem document whose `type` is one of the URIs below. Branch on the slug, never on `detail`, which is prose. The `type` URIs use the namespace `https://alvo.dev/errors/`, which does not resolve yet. The slug after the last `/` is the anchor on this page: `https://alvo.dev/errors/forbidden` is [`#forbidden`](#forbidden). ## `validation` **Status:** 422 · **`type`:** `https://alvo.dev/errors/validation` · **Returned by:** every host Schema-derived validation refused the request body. **Causes** - A field the entity declares `required` is missing or null. - A value breaks a declared facet: `maxLength`, `enum`, `format`, `precision` or `scale`. - The body names a field the entity does not declare, writes a read-only field, or points a `ref` at a row that does not exist. - The body is not a JSON object, is not valid JSON, repeats a field, or is larger, deeper or wider than the configured limits. - A batch body has no `rows` array, an empty one, too many rows, or a row without a valid `id`. - A Management API apply carried a descriptor that does not validate. **Fix:** Correct each field the violations point at; every violation carries a `pointer` and a `fixSuggestion`. **Violation codes:** `required`, `max-length`, `enum-value`, `format`, `format-not-evaluated`, `precision`, `scale`, `unknown-field`, `invalid-value`, `read-only-field`, `read-only-required-field`, `unresolved-reference`, `not-an-object`, `malformed-json`, `duplicate-field`, `body-too-large`, `body-too-deep`, `body-too-many-fields`, `not-a-batch`, `empty-batch`, `batch-too-many-rows`, `invalid-row-id`, `descriptor` **Guides:** [Write data safely](https://alvo.burgyn.online/guides/write-data/) · [Entities and fields](https://alvo.burgyn.online/guides/entities-and-fields/) · [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) ## `malformed-query` **Status:** 422 · **`type`:** `https://alvo.dev/errors/malformed-query` · **Returned by:** every host The query string or the request body is malformed — the shape is wrong, nothing is hidden. **Causes** - A `filter`, `order`, `select`, `limit`, `offset` or `after` parameter is malformed, repeated or contradicts another. - A filter names a field that is unavailable to the caller, or uses an operator the field's type does not support. - A filter is deeper, wider or lists more `in` candidates than the limits allow. - A query sent as a request body is not a JSON object, is not valid JSON, names a parameter twice, or is larger, deeper or wider than the limits allow. **Fix:** Correct the parameter each violation names in its `pointer`; the shape is wrong, nothing is hidden. **Violation codes:** `unavailable-field`, `unknown-operator`, `unsupported-operator-for-field`, `malformed-filter`, `malformed-filter-group`, `malformed-in-list`, `malformed-is-operand`, `invalid-filter-value`, `filter-too-deep`, `filter-too-wide`, `too-many-in-candidates`, `filter-beyond-port-limits`, `pattern-too-long`, `invalid-page-size`, `invalid-offset`, `invalid-cursor`, `conflicting-paging`, `malformed-order`, `repeated-sort-key`, `malformed-select`, `malformed-select-alias`, `colliding-projection-key`, `projection-too-wide`, `too-many-select-entries`, `too-many-query-values`, `unrepresentable-query-value`, `repeated-parameter`, `not-an-object`, `malformed-json`, `duplicate-field`, `body-too-large`, `body-too-deep`, `body-too-many-fields` **Guides:** [Read data: filter, sort, page](https://alvo.burgyn.online/guides/read-data/) · [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) ## `forbidden` **Status:** 403 · **`type`:** `https://alvo.dev/errors/forbidden` · **Returned by:** every host A policy refused the operation. **Causes** - The entity's access rules deny the operation, or the row being written would not satisfy them. - A before-hook's `reject` fired, or a `mutate` produced a value the target field's facets refuse. - A Management API caller does not reach the project access level the route needs. - In a batch, rows refused by policy or named twice are listed in `violations`, one entry per row. **Fix:** Change the request so the rules admit it, or call with a role the rules grant. A rule's refusal never says which rule refused; a before-hook's `reject` puts its own message in `detail`, written for the caller. **Violation codes:** `forbidden`, `duplicate-row` **Guides:** [Access rules](https://alvo.burgyn.online/guides/access-rules/) · [Before-hooks](https://alvo.burgyn.online/guides/before-hooks/) · [Computed fields and rollups](https://alvo.burgyn.online/guides/computed-and-rollups/) · [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/) · [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) ## `out-of-scope` **Status:** 403 · **`type`:** `https://alvo.dev/errors/out-of-scope` · **Returned by:** every host The presented API key's scopes do not cover this entity and operation. **Causes** - The presented API key's scopes do not cover this entity and operation. **Fix:** Grant the key the scope it needs, such as `*:read` or `*:write`. This is a different fix from `forbidden`, which is a rule. **Violation codes:** none — this refusal carries no itemised reasons **Guides:** [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/) · [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) ## `not-found` **Status:** 404 · **`type`:** `https://alvo.dev/errors/not-found` · **Returned by:** every host The row does not exist, or the caller's policy excludes it — indistinguishably. **Causes** - The row does not exist, or the caller's rules exclude it; the two are deliberately indistinguishable. - On the Management API, the project or revision does not exist. **Fix:** Check the id, and check that the caller's rules let it read the row. **Violation codes:** none — this refusal carries no itemised reasons **Guides:** [Read data: filter, sort, page](https://alvo.burgyn.online/guides/read-data/) · [Access rules](https://alvo.burgyn.online/guides/access-rules/) · [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) ## `precondition-failed` **Status:** 412 · **`type`:** `https://alvo.dev/errors/precondition-failed` · **Returned by:** every host The write carried a version the stored row does not have. **Causes** - The `If-Match` version does not match the stored row: someone else changed it, or your own earlier write already landed. - The request carries a precondition this API cannot evaluate: several or weak tags in `If-Match`, `If-None-Match` on a write, any precondition on a create, or a version on an entity without `audit`. - A Management API apply or rollback named a revision that is no longer current. **Fix:** Read the row (or the descriptor) again, reapply your change, and resend it with the new `ETag` in `If-Match`. **Violation codes:** none — this refusal carries no itemised reasons **Guides:** [Write data safely](https://alvo.burgyn.online/guides/write-data/) · [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/) ## `precondition-required` **Status:** 428 · **`type`:** `https://alvo.dev/errors/precondition-required` · **Returned by:** the Management API only, on any host that maps it — never a Data API route The write requires a precondition and carried none. **Causes** - A Management API apply or rollback carried no `If-Match`. **Fix:** Read the current revision and send it as `If-Match`. **Violation codes:** none — this refusal carries no itemised reasons **Guides:** [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/) ## `idempotency-conflict` **Status:** 409 · **`type`:** `https://alvo.dev/errors/idempotency-conflict` · **Returned by:** every host An idempotency key was reused for a different request. **Causes** - An `Idempotency-Key` was reused for a different request. **Fix:** Send a fresh key with a different request; reuse a key only to retry the identical request. **Violation codes:** none — this refusal carries no itemised reasons **Guides:** [Write data safely](https://alvo.burgyn.online/guides/write-data/) · [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/) ## `conflict` **Status:** 409 · **`type`:** `https://alvo.dev/errors/conflict` · **Returned by:** every host The request collides with stored state a database constraint guards. **Causes** - A value another record already holds on a `unique` field. - A delete that a `ref` declaring `onDelete: "restrict"` refuses, because other records still point at the row. **Fix:** Send a value no other record holds, or delete or repoint the records that reference this one, then retry. **Violation codes:** `unique`, `referenced` **Guides:** [Write data safely](https://alvo.burgyn.online/guides/write-data/) · [Entities and fields](https://alvo.burgyn.online/guides/entities-and-fields/) · [Indexes and uniqueness](https://alvo.burgyn.online/guides/indexes/) ## `destructive-change` **Status:** 409 · **`type`:** `https://alvo.dev/errors/destructive-change` · **Returned by:** the Management API only, on any host that maps it — never a Data API route The change would discard data and the caller did not ask for that. **Causes** - A Management API apply whose plan would discard data, sent without the destructive allowance. **Fix:** Resend with an explicit destructive allowance, or send a descriptor that keeps what the plan would drop. **Violation codes:** none — this refusal carries no itemised reasons **Guides:** [Apply and evolve your descriptor](https://alvo.burgyn.online/guides/apply-and-evolve/) ## `unauthenticated` **Status:** 401 · **`type`:** `https://alvo.dev/errors/unauthenticated` · **Returned by:** every host A credential was presented and cannot be used. **Causes** - A credential was presented and cannot be used: an unknown, revoked or expired key, a wrong secret, or a key not issued for the requested tenant. - A request with no credential at all is not refused with this slug: it runs as an anonymous caller, and the rules decide. **Fix:** Send a valid key in the header the `WWW-Authenticate` challenge names, `X-Alvo-Api-Key` by default. **Violation codes:** none — this refusal carries no itemised reasons **Guides:** [Authentication and API keys](https://alvo.burgyn.online/guides/authentication/) · [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) ## `unreadable-request` **Status:** 400, 408 or 413 · **`type`:** `https://alvo.dev/errors/unreadable-request` · **Returned by:** the standalone host; an embedded host only with `AddAlvoProblemDetails()` and `UseExceptionHandler()` The server refused the request before Alvo could read it. **Causes** - The web server refused the request before Alvo read it: a body over the server's request size limit (413), one arriving too slowly (408), or one whose framing broke (400). **Fix:** Send a different request: a smaller body, sent in one go, with valid framing. **Violation codes:** none — this refusal carries no itemised reasons **Guides:** [Write data safely](https://alvo.burgyn.online/guides/write-data/) · [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) ## `unsupported-media-type` **Status:** 415 · **`type`:** `https://alvo.dev/errors/unsupported-media-type` · **Returned by:** every host The request carried a body that is not declared as JSON, or carried no `Content-Type` at all. **Causes** - A request body was not declared as JSON, or carried no `Content-Type` at all, so nothing read it. **Fix:** Send `Content-Type: application/json`. The fix is the header, not the body. **Violation codes:** none — this refusal carries no itemised reasons **Guides:** [Write data safely](https://alvo.burgyn.online/guides/write-data/) · [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) ## `internal` **Status:** 500 · **`type`:** `https://alvo.dev/errors/internal` · **Returned by:** the standalone host; an embedded host only with `AddAlvoProblemDetails()` and `UseExceptionHandler()` An invariant Alvo itself relies on is broken. **Causes** - An invariant Alvo itself relies on is broken. The answer carries no reason, by design. **Fix:** Nothing in the request is at fault. The host's log has the exception and its stack trace. **Violation codes:** none — this refusal carries no itemised reasons **Guides:** [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) · [Running in production](https://alvo.burgyn.online/guides/production/) ## `function-failed` **Status:** 500 · **`type`:** `https://alvo.dev/errors/function-failed` · **Returned by:** the standalone host; an embedded host only with `AddAlvoProblemDetails()` and `UseExceptionHandler()` A CEL function failed while the write was evaluated, so nothing was written. **Causes** - A CEL function failed while a write was evaluated: a host function threw, a built-in refused (an overflow, a result too long), or an argument did not fit its parameter's type. Nothing was written. **Fix:** Check the function the detail names and the values the write passes to it; the host's own exception is in its log, never in the response. **Violation codes:** none — this refusal carries no itemised reasons **Guides:** [Custom CEL functions](https://alvo.burgyn.online/guides/custom-cel-functions/) · [Handle errors](https://alvo.burgyn.online/guides/handle-errors/) --- # Capabilities in this build Source: https://alvo.burgyn.online/reference/capabilities/ This page is generated from `GET {management}/projects/{project}/capabilities`, the answer the dashboard and the schema assistant read. Each sentence below is the framework's own, copied verbatim. ## Honoured - `entities` - `auth` - `tenancy` - `access` - `formats` ## Declared but not run in this build - `dynamicEntities` — no runtime entity can be created and the whole dynamic schema-registry driver is absent, so every governance limit declared here bounds nothing (planned) - `automation` — no rule is ever evaluated, so no declared action runs — which looks exactly like a condition that never matched - `templates` — a template referenced by an after-hook 'email' action is rendered, but one referenced only from an automation rule is not, because no rule is evaluated yet — and a 'bodyFile' is not read on either path - `webhooks` — an endpoint an after-hook posts to is delivered to, but one referenced only from an automation rule never receives anything; and no delivery is signed — 'secretRef' is not read and no Standard Webhooks HMAC header is sent, so a receiver cannot yet verify the sender (7.1), nor is the payload projected per endpoint (#152) - `functions` — no function is ever invoked, on any trigger or schedule it declares - `auth.providers` — only local credentials exist in this build — a person signs in with an address and a password Alvo holds; google, microsoft, github, apple and oidc sign-in are planned (#36), so a declared provider other than local offers no way in - `entity.storage` — an entity declared with 'storage: dynamic' is not created: this build has no dynamic schema-registry driver (planned, #41), so the entity gets no table, no Data API route and no records, and the apply drops it without refusing it - `entity.realtime` — no change is published over a realtime channel, because this build has none (planned, #38) — whatever an entity's 'realtime' says, and its default is true, nothing is sent and nothing can subscribe ## Refused at apply - `field.validation` — Field 'validation' is not evaluated yet, so a value the expression forbids is accepted — the field is not constrained at all. **Fix:** Remove 'validation'. Enforce the rule in a before-hook once #22 lands, or express it with a facet the API does validate — 'maxLength', 'precision'/'scale', enum 'values' or a 'format'. - `field.default` — Field 'default' is honoured as a literal, but not as a '$cel' expression: a CEL default is evaluated against the caller's context at insert time, which is the 'computed' machinery rather than a column default — so the value would be dropped and the field left null. **Fix:** Declare a literal default, which this build emits as a column DEFAULT, or remove 'default' and send the value explicitly on create (#113). - `entity.softDelete` — Soft delete is not supported yet: a delete would remove the row outright and reads would not exclude it, which is irrecoverable data loss where the schema promises recoverability. **Fix:** Remove 'softDelete' or track the soft-delete implementation issue. A flag written as false is not a declaration and maps normally. - `rollup.where` — A rollup's 'where' filter is not evaluated yet: the aggregate is still maintained, but it aggregates every record of the child entity instead of the subset this filter declares — a stored number that is silently wrong rather than absent. **Fix:** Remove 'where' and aggregate every child record, or move the distinction into the model: a separate child entity, or a second rollup once filtered rollups land. A partial implementation is deliberately not offered — an aggregate over the wrong row set costs more than this refusal. - `trigger.event` — A wildcard event subscription is not matched yet: no rule fires for it today, and on the build that does implement matching it would subscribe to every entity or every operation of every tenant at once — a cross-tenant fan-out nobody re-reads this descriptor to catch, because the event envelope carries no tenant for a subscription to be scoped by (#153). **Fix:** Name the entity and the operation exactly — 'entity.orders.created' rather than 'entity.orders.\*' — and declare one rule per pair. Wildcards are refused rather than accepted-and-ignored because a descriptor outlives the build that applied it: accepted today, it becomes the fan-out above on the day matching lands, with nothing between it and delivery. - `JSONata` — JSONata transformations are not evaluated yet: the action still runs, but with Alvo's canonical event envelope as its body instead of the transformation declared here — a delivery that succeeded carrying data you did not declare, which is indistinguishable from a bug in the consumer. **Fix:** Use a '{{...}}' template instead (e.g. "{{new.title}}"), which this build does render, or remove the transformation and accept the canonical envelope. A partial JSONata implementation is deliberately not offered: silently producing a different payload for the part it does not implement costs more than this refusal. Tracked in #149. - `email.data` — An 'email' action's 'data' is not rendered: it is validated when the descriptor is applied and then read by nothing, so the mail goes out with the referenced template's own subject and body and the values declared here are silently dropped — a message that was delivered without the data you declared, which is indistinguishable from a template bug. **Fix:** Move the values into the template's 'subject'/'body' as '{{...}}' placeholders over 'new'/'old'/'event'/'@user.id', which this build does render, or remove 'data'. A 'data.\*' placeholder root is new surface and lands with the PR that reads it. - `bodyFile` — A template's 'bodyFile' is not read yet: nothing in this build resolves a path inside a descriptor bundle, so an after-hook rendering this template would send a message with an empty body rather than fail. **Fix:** Move the body inline into the template's 'body' — it takes the same '{{...}}' placeholders — or stop referencing this template from an after-hook until bundle files are read. - `function` — The 'function' action is declared in the schema but not implemented in this build, so this hook invokes nothing — no function declared under 'functions' runs, on this hook or on any trigger or schedule it declares elsewhere. **Fix:** Use a 'webhook' or 'email' action, which this build does run. Custom functions are planned and the schema freezes their shape ahead of the implementation. - `http.call` — The 'http.call' action is declared in the schema but not implemented in this build, so this hook makes no request: the URL is never called, and 'headersSecretRef' is never read, so a receiver you believe is being notified is not. **Fix:** Declare the target under 'webhooks.endpoints' and use a 'webhook' action instead — that is the managed path, and it is the one this build delivers on. - `entity.update` — The 'entity.update' action is declared in the schema but not implemented in this build, so this hook writes nothing — no record is written or patched on the target entity, and no event is emitted for the write that did not happen. **Fix:** Perform the follow-up write through the Data API for now. 'entity.update' lands with automation, where the causation chain it creates can be bounded. --- # Limits and budgets Source: https://alvo.burgyn.online/reference/limits/ Each value below is read from the member that enforces it. A limit with a key can be changed in configuration; the others are fixed. | Limit | Value | Configure with | What happens | |---|---|---|---| | Default page size | 50 | `Alvo:Api:DefaultPageSize` | A list request that sends no `limit` gets a page of this many rows. | | Maximum page size | 200 | `Alvo:Api:MaxPageSize` | A larger `limit` is refused with `422 malformed-query` (`invalid-page-size`); it is never silently capped. | | Request body | 1048576 bytes (1 MiB) | `Alvo:Api:MaxRequestBodyBytes` | A larger body is refused before it is parsed: `422 validation` (`body-too-large`) on a write, `422 malformed-query` on a query sent as a body. | | Payload depth | 32 | `Alvo:Api:MaxPayloadDepth` | A body nested deeper is refused with `422` (`body-too-deep`). | | Payload keys | 512 | `Alvo:Api:MaxPayloadKeys` | A body carrying more property names, counted at every depth, is refused with `422` (`body-too-many-fields`). In a batch the count applies per row. | | Batch rows | 1000 | `Alvo:Api:MaxBatchRows` | A batch with more rows is refused with `422 validation` (`batch-too-many-rows`) and nothing is written; split it into several batches. | | `Idempotency-Key` length | 255 bytes | `Alvo:Api:MaxIdempotencyKeyBytes` | A longer `Idempotency-Key`, measured in UTF-8 bytes, is refused with `422 malformed-query` (no violation code) rather than shortened, so two different keys never collapse into one. It can only be lowered. | | Filter depth | 32 | — | A filter nested deeper is refused with `422 malformed-query` (`filter-too-deep`). | | Filter terms | 256 | — | A filter with more comparisons and connectives in total is refused with `422 malformed-query` (`filter-too-wide`). | | `in` candidates | 1000 | — | An `in` list with more values is refused with `422 malformed-query` (`too-many-in-candidates`). | | Dev-key secret minimum | 32 characters | — | A dev API key whose secret is shorter stops the host at start. `openssl rand -hex 16` produces exactly this length. | | CEL nesting depth | 32 | — | A CEL expression nested deeper (parentheses, ternaries, unary operators, function arguments) is refused before it runs: a Management API apply answers `422 validation` (`descriptor`), and a host booting with it refuses to start. | See [Configuration keys](https://alvo.burgyn.online/reference/configuration/) for every key, and [Problem types](https://alvo.burgyn.online/reference/problem-types/) for the refusal a request over a limit receives. --- # Data API conventions Source: https://alvo.burgyn.online/data-api/conventions/ Every entity in an applied descriptor gets the same ten routes, the same query grammar and the same headers. This page lists them. The guides show them in use: [Read data](https://alvo.burgyn.online/guides/read-data/), [Write data safely](https://alvo.burgyn.online/guides/write-data/) and [Handle errors](https://alvo.burgyn.online/guides/handle-errors/). ## OpenAPI document Every descriptor generates its own OpenAPI document. The standalone host serves it at `GET /openapi/v1.json`, with an API browser at `/scalar`, unless `Alvo:Docs:Enabled` is false; both are readable without a key. [Data API: example](https://alvo.burgyn.online/reference/data-api/) is the document for `examples/vehicle-registry`. An embedded host that calls ASP.NET Core's `AddOpenApi()` and `MapOpenApi()` gets Alvo's routes and schemas in its own document; Alvo does not add an OpenAPI document on its own. ## Base path and routes The routes sit under `AlvoApiOptions.RoutePrefix` (configuration key [`Alvo:Api:RoutePrefix`](https://alvo.burgyn.online/reference/configuration/#alvoapi)), default `/api`; an empty prefix mounts them at the root. `{entity}` is the entity's name as the descriptor declares it, and `{id}` is a row's UUID. | Method and path | Does | Gated by the rule | Success | |---|---|---|---| | `GET {prefix}/{entity}` | List rows, filtered, sorted and paged | `list` | 200, a page | | `POST {prefix}/{entity}/query` | The same list, with the query as a JSON body | `list` | 200, a page | | `GET {prefix}/{entity}/{id}` | Read one row | `get` | 200, the row; 304 on a matching `If-None-Match` | | `POST {prefix}/{entity}` | Create a row with a generated id | `create` | 201, `Location` and the row | | `PUT {prefix}/{entity}/{id}` | Create the row under this id, or replace it | `create` and `update` | 201 on create, 200 on replace | | `PATCH {prefix}/{entity}/{id}` | Merge the body into the row | `update` | 200, the row | | `DELETE {prefix}/{entity}/{id}` | Delete the row | `delete` | 204, no body | | `POST {prefix}/{entity}/batch` | Create many rows in one transaction | `create` | 200, `items` and `affected` | | `PATCH {prefix}/{entity}/batch` | Update many rows in one transaction | `update` | 200, `items` and `affected` | | `DELETE {prefix}/{entity}/batch` | Delete many rows in one transaction | `delete` | 200, empty `items` and `affected` | An entity the descriptor does not declare has no route, so it answers the router's 404 with no problem document. A write answers with the row as a `GET` by the same caller would show it, or with its `id` alone when the caller may not read the row. Every response from these routes carries `Cache-Control: no-store`. ## Query grammar PostgREST's syntax, so that a client or an agent recognises it. One parameter per term; every term must hold. | Form | Means | |---|---| | `{field}={op}.{value}` | A filter: `year=gte.2020`. The parameter name is a field; an unknown name is refused, never ignored. | | `not.{field}={op}.{value}`, `not.or=(…)`, `not.and=(…)` | Negates the term or the group. One `not.` at most. | | `or=({term},{term},…)` | Any term holds. A term inside a group is `{field}.{op}.{value}`, optionally prefixed with `not.`. | | `and=({term},{term},…)` | Every term holds. A group nests with `=` inside it: `or=(a.eq.1,and=(b.eq.2,c.eq.3))`. | | `order={field}[.asc\|.desc][.nullsfirst\|.nullslast],…` | Sort keys, comma-separated. Ascending and nulls last unless stated. | | `select={field},{alias}:{field},…` | The fields each row carries, optionally renamed. | | `limit={n}` | Page size, 1 to 200; default 50. | | `after={cursor}` | Continue from the page whose `next` this is. | | `offset={n}` | Skip `n` rows; not together with `after`. | Reserved, so no descriptor may declare a field with these names: `order`, `limit`, `offset`, `after`, `select`, `or`, `and`, `not`. **Deviation from PostgREST:** a nested group is written `and=(…)` inside the outer group, where PostgREST writes `and(…)`, and negation is a prefix on the parameter name (`not.make=eq.Skoda`) rather than on the value (`make=not.eq.Skoda`, which is refused with `unknown-operator`; [#350](https://github.com/Burgyn/MMLib.Alvo/issues/350)). ### Operators per field type | Operator | Matches | Allowed on | |---|---|---| | `eq`, `neq` | equal, not equal | every field type | | `in` | one of a list, `in.(a,b,c)` | every field type | | `gt`, `gte`, `lt`, `lte` | ordering | `string`, `text`, `enum`, `integer`, `decimal`, `date`, `datetime` | | `like`, `ilike` | a pattern with `%` and `_`; `ilike` ignores case | `string`, `text`, `enum` | | `is` | `null` on any field; `true` or `false` | `null`: every type; `true`, `false`: `boolean` | An operator is matched exactly: `EQ` is not an operator. In a URL, a literal `%` in a pattern is written `%25`. ### The query as a body `POST {prefix}/{entity}/query` takes a JSON object whose members are the parameters above, each value written as it would follow `=` in the URL: `{"make": "in.(Skoda,Renault)", "order": "year", "limit": 100}`. Values are decoded, so `%` is literal. An array repeats a parameter. Member names compare case-insensitively, and a name that appears twice is refused. `{}` is the empty query. It is a read gated by `list`, honours `Prefer: count`, and ignores `If-Match`, `If-None-Match` and `Idempotency-Key`. ## Pages A list always answers one page, as an object with exactly these members: | Member | Holds | |---|---| | `items` | The rows of this page. | | `next` | The cursor for the next page, or `null` on the last one. Opaque, at most 512 characters. | | `count` | The number of rows the whole query matches when the request sent `Prefer: count=exact`, otherwise `null`. | The cursor carries no data; a stale, forged or foreign cursor returns an empty page. `count` covers only the rows the caller's rules admit, and is computed in a second statement, so a concurrent write can make it differ from the rows by one. `Prefer: count=planned` and `count=estimated` return the exact count; an unrecognised preference is ignored. Sizes and budgets are in [Limits and budgets](https://alvo.burgyn.online/reference/limits/). ## Headers | Header | Direction | Meaning | |---|---|---| | `X-Alvo-Api-Key` | request | The API key, `.`. The name is [`Alvo:Auth:HeaderName`](https://alvo.burgyn.online/reference/configuration/#alvoauth); `Cookie` is refused. | | `X-Alvo-Tenant` | request | The tenant the caller asks to act in. The name is `Alvo:Auth:TenantHeaderName`; `Cookie` is refused. | | `Content-Type` | request | `application/json` or any `application/*+json`, on every request with a body. | | `If-Match` | request | The `ETag` a `PATCH`, `PUT` or `DELETE` expects the row to have. `*` asks only that the row exist. | | `If-None-Match` | request | On a read of one row, the `ETag` you hold; 304 when it is current. Refused on a write. | | `Idempotency-Key` | request | Makes any write (`POST`, `PUT`, `PATCH`, `DELETE`, or a batch) safe to retry; at most 255 UTF-8 bytes. | | `Prefer` | request | `count=exact` fills the page's `count`. | | `ETag` | response | The row's version, on an entity with `audit`; strong, opaque. | | `Location` | response | On a 201, the new row's URL, including any path base or route-group prefix. | | `Preference-Applied` | response | Which preference was honoured, such as `count=exact`. | | `Accept-Post`, `Accept-Patch` | response | On a 415, the media types the route accepts. | | `Cache-Control` | response | `no-store`, on every response from a generated route. | | `WWW-Authenticate` | response | On a 401, the scheme and the header name the key must be sent in. | ## Versions and `If-Match` | Situation | Answer | |---|---| | `If-Match` equals the row's `ETag` | The write proceeds. | | `If-Match` names an older version | 412 `precondition-failed` | | A weak tag, several tags, or a tag on an entity without `audit` | 412 `precondition-failed` | | `If-Match: *` | Accepted on every entity; asks only that the row exist. | | `If-None-Match` on a write, or any precondition on a create | 412 `precondition-failed` | | No `If-Match` | The write proceeds without a check. | ## Batches | | `POST …/batch` | `PATCH …/batch` | `DELETE …/batch` | |---|---|---|---| | Body | `{"rows": [ {fields}, … ]}` | `{"rows": [ {"id": …, fields}, … ]}` | `{"rows": [ "id", … ]}` | | Answer | 200, `items` and `affected` | 200, `items` and `affected` | 200, empty `items` and `affected` | Every row is checked before any row is written, and one transaction writes all of them or none. Up to 1000 rows; a row named twice is refused; no `If-Match`. A refusal lists every bad row: a 422 whose pointers start with `/rows//`, or a 403 whose `violations` name each row a rule refused. A `409 conflict` names the field and no row index. ## `Idempotency-Key` | Situation | Answer | |---|---| | A new key | The request runs, and the key is stored with the ids of the rows it wrote, scoped to the caller. | | The same key and the same request again | No second write; the original status, with the row read again under the caller's current rules. A replayed `PUT` answers 200 without `Location`. | | The same key with a different request | 409 `idempotency-conflict` | | A key from a caller without an API key | 422 `malformed-query` | | A key over 255 bytes | 422 `malformed-query` | ## Statuses | Status | When | |---|---| | 200 | A row, a page, or a batch result. | | 201 | Created, with `Location`. | | 204 | Deleted. | | 304 | `If-None-Match` matches the current version (a read of one audited row). | | 401 | A key was presented and cannot be used. A request without a key is judged by the rules instead. | | 403 | A rule or a before-hook refused (`forbidden`), or the key's scopes do not cover it (`out-of-scope`). | | 404 | The row does not exist or the caller's rules exclude it; or, without a problem document, the entity does not exist. | | 405 | A verb the route does not have, such as `GET …/query`; from routing, without a problem document. | | 409 | A `unique` or `restrict` conflict (`conflict`), or a reused `Idempotency-Key` (`idempotency-conflict`). | | 412 | A precondition that does not hold or cannot be evaluated. | | 415 | A body not declared as JSON. | | 422 | A malformed body (`validation`) or query (`malformed-query`). | | 413, 408, 400 | The web server refused the request before Alvo read it (`unreadable-request`). | | 500 | `internal` or `function-failed`. | ## Problem documents Every refusal is an RFC 9457 problem document, `application/problem+json`, with `type`, `title`, `status`, `detail` and, when there are reasons to itemise, `violations`: each with `pointer`, `code`, `message` and `fixSuggestion`. Branch on the slug at the end of `type`. Every slug, its causes and its fix are in [Problem types](https://alvo.burgyn.online/reference/problem-types/); `unreadable-request`, `internal` and `function-failed` come only from a host that called `AddAlvoProblemDetails()`, which the standalone host does. Design notes: [`docs/architecture/data-api.md`](https://github.com/Burgyn/MMLib.Alvo/blob/main/docs/architecture/data-api.md). ---