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

Entities and fields

Add an entity to the descriptor, give its fields types and constraints, decide per caller who reads and writes a field, and link entities with references.

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

An entity lives at /entities/<name> and each field at /entities/<name>/fields/<field>. 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:

    Terminal
    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:

    help-desk.alvo.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 covers them in depth.

  3. Create a customer as agent:

    Request
    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"}'

    201 Created

    Response
    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.

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:

    Request
    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"}'

    422 Unprocessable Entity

    Response
    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:

    Request
    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"}'

    409 Conflict

    Response
    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.

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:

    Request
    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}'

    422 Unprocessable Entity

    Response
    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:

    Request
    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."}'

    201 Created

    Response
    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,
    "internal_note": "Pays late; call before renewing."
    }
  3. The agent reads the same customer: credit_limit is there, internal_note is not.

    Request
    curl -sS -X GET http://localhost:8080/api/customers/1352eb30-09fc-417c-84bf-91cefa256052 \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET"

    200 OK

    Response
    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.

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:

    help-desk.alvo.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 covers every way to apply a change.)

    Terminal
    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:

    Request
    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"}'

    201 Created

    Response
    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"
    }
    Request
    curl -sS -X POST http://localhost:8080/api/tickets \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
    -H "Content-Type: application/json" \
    -d '{"title":"VPN drops every hour","customer_id":"0b7f3c1e-5a2d-4e8b-9c6f-1d2e3f4a5b6c"}'

    201 Created

    Response
    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:

    Request
    curl -sS -X POST http://localhost:8080/api/tickets \
    -H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \
    -H "Content-Type: application/json" \
    -d '{"title":"Printer offline","customer_id":"9a8b7c6d-0000-4000-8000-000000000000"}'

    422 Unprocessable Entity

    Response
    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:

    Request
    curl -sS -X DELETE http://localhost:8080/api/customers/0b7f3c1e-5a2d-4e8b-9c6f-1d2e3f4a5b6c \
    -H "X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET"

    409 Conflict

    Response
    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.

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 explains the model.

A field’s type decides which facets it takes. A facet on any other type is refused at apply.

typeHoldsFacets
stringbounded textmaxLength, format
textunbounded prosenone
integera whole numbernone
decimalan exact numberprecision (all digits) and scale (digits after the point), both required
booleantrue or falsenone
date, datetimea day, an instantnone
uuidan idnone
jsonany JSON valuenone
enumone value of a fixed listvalues, required
refthe id of a row in another entityentity, required; onDelete: restrict, cascade or setNull

Every type also takes required, unique, default, hidden, readOnly, index and 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 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.

Renames. Renaming a field or an entity without losing its data takes renamedFrom; see Apply and evolve your descriptor.

StatusProblem typeWhenFixReturned by
409conflictAnother 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
422validationThe 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. In this build that refusal ends the process with exit code 139 instead of 78 (#340).

Computed fields and rollups: derive a value from the row’s own fields, or from the rows that point at it.