Apply and evolve your descriptor
Change a running backend's descriptor safely: preview the plan, apply it against the revision you read, rename without losing data, drop data only on purpose, and roll back.
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, pluscurlandjq. - The
adminkey (rolesadmin,authenticated). This page’s descriptor grants that role thedeveloperlevel of the Management API.
Three ways to apply a change
Section titled “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: 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) 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: 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:
-
Apply(the default) applies the plan, still refusing any step that discards data. That is the loop Run your own descriptor uses:Terminal docker compose up -d --wait --force-recreate alvo -
Verifyrefuses 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: underApply, every replica of a rolling deploy attempts the DDL and the application needs DDL rights on its own database. -
Skipserves 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.
1. Give the API a way in
Section titled “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:
"access": { "developer": "'admin' in @user.roles"}Start the stack over it. The first command deletes the stack’s database:
docker compose down --volumescurl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/apply-and-evolve/01-base.alvo.jsondocker compose up --wait --wait-timeout 90A developer may apply and roll back, but not change the access block itself; that takes admin.
For coding agents lists the three levels.
2. Preview a change
Section titled “2. Preview a change”The change adds a category to tickets:
{ "$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" } } }}-
Read the current descriptor. Its
revisionis the version you are editing:Request GET /management/projects/help-desk/descriptor HTTP/1.1X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET200 OK
Response HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8{"project": "help-desk","revision": 1} -
Send the whole new descriptor with
?dryRun=true. The body is{"descriptorJson": "<the file as one string>"}, andIf-Matchcarries the revision you read. The answer is the plan, and nothing is applied:Request PUT /management/projects/help-desk/descriptor?dryRun=true HTTP/1.1X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRETIf-Match: "1"Content-Type: application/json200 OK
Response HTTP/1.1 200 OKContent-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), 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
Section titled “3. Apply it”-
A write must say which revision it replaces. Without
If-Match, the API refuses rather than risk overwriting a change it cannot see:Request PUT /management/projects/help-desk/descriptor HTTP/1.1X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRETContent-Type: application/json428 Precondition Required
Response HTTP/1.1 428 Precondition RequiredContent-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."} -
Send
If-Match, anIdempotency-Key, and areason, which the history keeps. From a shell,jqbuilds the body from the file:Terminal 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.jsonREVISION="$(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:
Request PUT /management/projects/help-desk/descriptor HTTP/1.1X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRETIf-Match: "1"Idempotency-Key: help-desk-add-categoryContent-Type: application/json200 OK
Response HTTP/1.1 200 OKContent-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.
-
The history lists every revision. Revision 1 is the one the host recorded when it started, with no author or reason:
Request GET /management/projects/help-desk/revisions HTTP/1.1X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRET200 OK
Response HTTP/1.1 200 OKContent-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}] -
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 fromGET …/revisions/{n}. The key can only repeat that request: the same key with a different body is refused, as here:Request PUT /management/projects/help-desk/descriptor HTTP/1.1X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRETIf-Match: "1"Idempotency-Key: help-desk-add-categoryContent-Type: application/json409 Conflict
Response HTTP/1.1 409 ConflictContent-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
Section titled “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":
PUT /management/projects/help-desk/descriptor HTTP/1.1X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRETIf-Match: "1"Content-Type: application/json412 Precondition Failed
HTTP/1.1 412 Precondition FailedContent-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
Section titled “5. Rename without losing data”To rename a field, rename its key and name the old one in renamedFrom. Here body becomes details:
"details": { "type": "text", "renamedFrom": "body" }The plan renames the column, and every value stays:
PUT /management/projects/help-desk/descriptor?dryRun=true HTTP/1.1X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRETIf-Match: "1"Content-Type: application/json200 OK
HTTP/1.1 200 OKContent-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:
409 Conflict
HTTP/1.1 409 ConflictContent-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
Section titled “6. Make a destructive change deliberately”Removing a field drops its column and all its data. This descriptor removes priority:
"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"] }}-
Applied like any other change, it is refused, and nothing is touched:
409 Conflict
Response HTTP/1.1 409 ConflictContent-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."} -
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": truetoo (#343); a dry run applies nothing either way. The destructive steps are marked:Request PUT /management/projects/help-desk/descriptor?dryRun=true HTTP/1.1X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRETIf-Match: "1"Content-Type: application/json200 OK
Response HTTP/1.1 200 OKContent-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} -
When you are sure, send the same body without
?dryRun=true, with areason:Request PUT /management/projects/help-desk/descriptor HTTP/1.1X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRETIf-Match: "1"Content-Type: application/json200 OK
Response HTTP/1.1 200 OKContent-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
Section titled “7. Roll back”A rollback restores an earlier revision’s descriptor. It does not rewrite history: it appends a new revision.
-
Preview the rollback to revision 1.
If-Matchcarries the current revision. Going back drops thecategorycolumn that revision 2 added, so in this build the preview is refused without"allowDestructive": true(#343):Request POST /management/projects/help-desk/revisions/1/rollback?dryRun=true HTTP/1.1X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRETIf-Match: "2"Content-Type: application/json{"allowDestructive": true}200 OK
Response HTTP/1.1 200 OKContent-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} -
Roll back, with a reason:
Request POST /management/projects/help-desk/revisions/1/rollback HTTP/1.1X-Alvo-Api-Key: admin.$ALVO_ADMIN_KEY_SECRETIf-Match: "2"Content-Type: application/json{"allowDestructive": true,"reason": "Category was premature."}200 OK
Response HTTP/1.1 200 OKContent-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} -
The history now holds three revisions, and the third records which one it restored:
200 OK
Response HTTP/1.1 200 OKContent-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:
curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/apply-and-evolve/01-base.alvo.jsonOtherwise 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
Section titled “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
records each decision.
What can go wrong
Section titled “What can go wrong”| Status | Problem type | When | Fix | Returned by |
|---|---|---|---|---|
| 403 | 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 | 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 | 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 | 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 | 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 | The write carried no If-Match. | Read the current revision and send it as If-Match: "<revision>". | 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.
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 withallowDestructive, or setAlvo__Schema__AllowDestructive=truefor that one start.Descriptor validation failed:: the descriptor is invalid. In this build the process exits with code 139 instead of 78 (#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.
AllowDestructivealone does not make an older descriptor serve.
Reference
Section titled “Reference”- Management API: every route and its body (
descriptorJson,allowDestructive,author,reason). - Configuration:
Alvo:Schema:StartupandAlvo:Schema:AllowDestructive. - Descriptor keys:
access, a field’srenamedFrom, an entity’srenamedFrom. - Problem types:
forbidden,destructive-change,idempotency-conflict,precondition-failed,precondition-required,validation. - The startup mode and production, in depth:
docs/architecture/host.md.
Authentication and API keys: decide who your callers are, the first step of securing what you modelled.