Skip to content

Plan and publish a cart branch

Write metadata to a cart branch, review what publishing it would do — both the changed files and the DDL that would run — and then publish it to main in one reviewed step.

  • A principal holding admin.metadata.plan (for planning), admin.metadata.apply (for applying and publishing), and admin.metadata.module.read (for diffing). Planning also needs global admin.metadata.object.read and admin.metadata.field.read: the computed diff names every module, object and field it touches, so admin.metadata.plan alone is not enough — a principal with the plan grant but no read grants gets 403. All of these are scoped globally, not per-module. Writing directly to main requires admin.metadata.apply too, in addition to the operation’s own permission — a create/update/delete-only principal gets 403 there. This guide’s own writes target a cart branch instead, which needs no apply grant; only the plan and publish steps do.
  • The object or field you plan to change already has a module applied to main, or you are creating one from scratch. See From metadata to database.

This flow uses four operations: the existing metadata write routes scoped to a branch, POST /v1/plan, GET /v1/metadata@{branchId}/diff, and POST /v1/metadata@{branchId}/publish. See the HTTP API reference for each route’s exact path, method, required permission, and response schema — those facts come straight off the route tree and its types, so this guide does not re-copy them and risk drifting out of sync.

{branchId} is any git branch name other than main. A branch id containing / must be percent-encoded (feat/x -> feat%2Fx) — the write and branch-lifecycle routes all capture the branch as a single URL segment.

Writing to any branch id other than main commits the change to that branch without applying it — no DDL runs yet:

Terminal window
curl -X PUT https://your-server/v1/metadata@feat-x/core \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"label": "Core"}'

An agent, a script, or a human can make as many writes as needed on the same branch before moving to review — each write is its own commit on feat-x.

POST /plan with a branch field computes the DDL that publishing feat-x would execute, without touching the database:

Terminal window
curl -X POST https://your-server/v1/plan \
-H "authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"branch": "feat-x"}'

The response’s changes array lists every planned change with its risk level and the DDL statements it runs, in execution order; source names the branch and the exact commit the plan was computed against, and status is "planned" — nothing has run yet.

Plan never hides a change. Every change also carries a disposition — "execute", "skip", or "refuse" — computed server-side from the same predicate the apply/publish gate itself uses, so a client reads it directly instead of deriving “would this be refused?” from risk or any other field. An orphan drop (a column, junction table, or module schema no longer in metadata) is still listed, with its DDL and risk, marked "disposition": "skip" — apply and publish leave it in place (with a warning) rather than running it, unless the request also carries destructive: true.

The managed foreign-key column drop a Lookup-to-Junction relationship-strategy migration itself decided is safe once its data has moved is exempt from the destructive gate and always "disposition": "execute", even without destructive: true — never "skip". A change rated destructive risk that apply cannot skip is listed like any other change, but with "disposition": "refuse" under destructive: false, since apply/publish refuse the whole run over it rather than leaving just that one change in place — for example, an unsafe USING type narrowing or a generated column’s storage mode changing (a drop-and-re-add).

GET /metadata@{branchId}/diff lists the files the branch adds, modifies, or deletes relative to main, independent of what DDL those changes imply:

Terminal window
curl https://your-server/v1/metadata@feat-x/diff \
-H "authorization: Bearer $TOKEN"

Each entry has a path and a status — see DiffEntry in the HTTP API reference for the exact set of status values. This only needs admin.metadata.module.read — a reviewer who can read metadata but not apply it can still see what changed.

Once the plan and diff look right, publish the branch: this fast-forwards main onto the branch and applies it in one step.

Terminal window
curl -X POST https://your-server/v1/metadata@feat-x/publish \
-H "authorization: Bearer $TOKEN"

The response is the same ApplyResult shape /plan and /apply return, with status: "applied" and source.kind: "branch" naming the branch and the commit that was published. On success the branch is deleted; main’s HEAD has advanced to the published commit.

POST /plan with no branch field (planning main’s own working tree) now reports no changes for whatever the published branch added — its changes array is empty, because main already has what the branch had.

  • Publishing an unknown branch answers 404 BRANCH_NOT_FOUND; it never creates the branch as a side effect of the lookup.
  • Publishing main to itself (POST /metadata@main/publish) answers 400 MAIN_IS_NOT_A_CART_BRANCH — there is nothing to fast-forward. The same code and status reject POST /plan with {"branch": "main"}.
  • If a branch’s metadata fails to compile, publish answers 422 naming every diagnostic and leaves main unmoved; the cart worktree is left on disk so an operator can inspect it before retrying.
  • GET /metadata@main/diff redirects to the branch-less read surface and then 404s — main is never a valid diff target.
  • Every branch-touching route on this page – publish, diff, and POST /plan with a branch field – answers 409 METADATA_NOT_IN_CHECKOUT when the server’s metadata directory is not inside a git working tree; a checkout is needed to read and write branches against. POST /plan with no branch field (the Verification step above) is the one exception: it plans main’s own working tree directly and never touches the branch machinery, so it never answers this 409.