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.
Before you start
Section titled “Before you start”- The stack from Run your own descriptor, run from its
alvo-help-deskdirectory withCOMPOSE_FILEand that page’s secrets exported in this shell. - Two keys:
agent(rolesagent,authenticated) andadmin(rolesadmin,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
Section titled “1. Add an entity”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.
-
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 --volumescurl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/entities-and-fields/01-basic.alvo.jsondocker compose up --wait --wait-timeout 90 -
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"}}}}rulesholds one CEL condition per operation. Alvo is default-deny: an entity withoutrulesanswers nobody, so every new entity needs them. Access rules covers them in depth. -
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 CreatedContent-Type: application/json; charset=utf-8Location: /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"}tierwas filled from itsdefault, andcredit_limitisnull.internal_noteis missing because it is hidden from this caller; step 3 explains why.
2. Constrain the values
Section titled “2. Constrain the values”The API checks every facet before anything is written, and each refusal names the field.
requiredmakes the field NOT NULL: a create must carry it, and an update may not set it tonull.maxLengthbounds astring, counted in Unicode code points.uniquemakes the value unique across the entity, and per tenant on a tenant-scoped one.defaultis a literal of the field’s own type, within its facets. It becomes the column’s default.
-
Leave out the required
nameand 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 EntityContent-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."}]} -
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 ConflictContent-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
Section titled “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.
-
An agent who sends
credit_limitis 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 EntityContent-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."}]} -
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 CreatedContent-Type: application/json; charset=utf-8Location: /api/customers/1352eb30-09fc-417c-84bf-91cefa256052{"id": "1352eb30-09fc-417c-84bf-91cefa256052","credit_limit": 5000,"internal_note": "Pays late; call before renewing."} -
The agent reads the same customer:
credit_limitis there,internal_noteis 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 OKContent-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
Section titled “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.
-
Add a
ticketsentity whosecustomer_idpoints atcustomers: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"}}}}restrictis the default foronDelete; it is written out here so you can see it.assignee_idpoints atusers, the built-in auth entity, and holds a user’s id. -
Apply it by recreating the
alvocontainer. 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.jsondocker compose up -d --wait --force-recreate alvo -
Create a customer under an id you choose (a
PUTto 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 CreatedContent-Type: application/json; charset=utf-8Location: /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 CreatedContent-Type: application/json; charset=utf-8Location: /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"} -
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 EntityContent-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."}]} -
Delete the customer while a ticket still points at it, and
restrictrefuses: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 ConflictContent-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
Section titled “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 explains the model.
Options and variations
Section titled “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, format |
text | unbounded prose | none |
integer | a whole number | none |
decimal | an exact number | precision (all digits) and 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, required |
ref | the id of a row in another entity | entity, 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.
What can go wrong
Section titled “What can go wrong”| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 409 | 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 | 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. In
this build that refusal ends the process with exit code 139 instead of 78
(#340).
Reference
Section titled “Reference”- Descriptor keys:
entities, fields,formats, rules. - Problem types:
conflict,validation. - How a database constraint becomes a 409:
docs/architecture/data-api.md.
Computed fields and rollups: derive a value from the row’s own fields, or from the rows that point at it.