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.
Prerequisites
Section titled “Prerequisites”- A principal holding
admin.metadata.plan(for planning),admin.metadata.apply(for applying and publishing), andadmin.metadata.module.read(for diffing). Planning also needs globaladmin.metadata.object.readandadmin.metadata.field.read: the computed diff names every module, object and field it touches, soadmin.metadata.planalone is not enough — a principal with the plan grant but no read grants gets403. All of these are scoped globally, not per-module. Writing directly tomainrequiresadmin.metadata.applytoo, in addition to the operation’s own permission — a create/update/delete-only principal gets403there. 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.
Routes
Section titled “Routes”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.
1. Write to a cart branch
Section titled “1. Write to a cart branch”Writing to any branch id other than main commits the change to that
branch without applying it — no DDL runs yet:
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.
2. Plan the branch
Section titled “2. Plan the branch”POST /plan with a branch field computes the DDL that publishing
feat-x would execute, without touching the database:
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).
3. Review what files changed
Section titled “3. Review what files changed”GET /metadata@{branchId}/diff lists the files the branch adds, modifies,
or deletes relative to main, independent of what DDL those changes
imply:
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.
4. Publish
Section titled “4. Publish”Once the plan and diff look right, publish the branch: this fast-forwards
main onto the branch and applies it in one step.
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.
Verification
Section titled “Verification”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
mainto itself (POST /metadata@main/publish) answers400 MAIN_IS_NOT_A_CART_BRANCH— there is nothing to fast-forward. The same code and status rejectPOST /planwith{"branch": "main"}. - If a branch’s metadata fails to compile, publish answers
422naming every diagnostic and leavesmainunmoved; the cart worktree is left on disk so an operator can inspect it before retrying. GET /metadata@main/diffredirects to the branch-less read surface and then404s —mainis never a valid diff target.- Every branch-touching route on this page –
publish,diff, andPOST /planwith abranchfield – answers409 METADATA_NOT_IN_CHECKOUTwhen the server’s metadata directory is not inside a git working tree; a checkout is needed to read and write branches against.POST /planwith nobranchfield (the Verification step above) is the one exception: it plansmain’s own working tree directly and never touches the branch machinery, so it never answers this 409.
See also
Section titled “See also”- From metadata to database — how a metadata write becomes a database schema.
- HTTP API reference — the rendered OpenAPI document,
including every field on
ApplyResultand every error code named above.