Upgrade guide — move between major versions
Upgrade ObjectStack in two halves on separate clocks — the platform runtime and your metadata app. What each moves, and where each major's checklist lives.
There are two upgrades on this platform, and they run on separate clocks:
| Platform runtime | Metadata app | |
|---|---|---|
| Ships as | a Docker image (or the os CLI on a host) | a compiled artifact, dist/objectstack.json |
| Versioned by | our release train (X.Y.Z) | your own catalog |
| Whose cadence | ours | yours |
| The upgrade action | move the image tag, or the dependency pins, then restart | os migrate meta --from 16, then rebuild and ship |
| Touches your metadata? | it never rewrites your metadata — but it can start refusing it (measured) | yes — you edit your source, guided by the tool's change list |
Most upgrade questions are really the question which of these am I doing. You can do either one alone. A platform move does not rewrite your app, and republishing your app does not move the runtime.
The short version. Moving the runtime forward one major keeps working with metadata authored against the previous major — the loader converts it as it reads. Falling two majors behind is what breaks, because the conversion window is one major wide. See when a platform move forces an app move.
A minor is not automatically a no-op. The paragraph above is about metadata compatibility across a major boundary, and that is the only thing the major number promises. It does not promise that moving between two releases inside one major changes nothing. v17's own minors have narrowed what the write path accepts, flipped a self-registration default to the safe end, renamed a published SDK namespace with no aliases, and started enforcing constraints that were declared but inert — none of which the protocol handshake or the metadata conversion window has anything to say about. Read the checklist for every release you cross, not only for every major: the table below lists them.
The platform runtime
The platform ships as a single version-locked train: every @objectstack/*
package shares one version number, and that number is the platform version.
Moving the tag
The official image is ghcr.io/objectstack-ai/objectstack, and its tags mirror
@objectstack/cli versions: the exact X.Y.Z, plus the rolling X.Y, X and
latest tags. Pin the exact version in production and move it deliberately:
# docker-compose.yml, or your orchestrator's manifest
image: ghcr.io/objectstack-ai/objectstack:17.7.0On a host running the artifact directly under systemd, the same move is a file swap: replace the artifact and restart the service. Roll back by restoring the previous artifact.
See Self-hosting for both deployment shapes in full.
Moving the dependency pins
Not every deployment moves a tag. An app that consumes the platform as npm
dependencies — @objectstack/* in its own package.json, which is the shape
create-objectstack scaffolds — carries the
platform version in its dependency tree and moves it there. There is no os
command for this move; it is three steps you run yourself.
- Bump every
@objectstack/*dependency together, to the same version. The train is version-locked — every package on it shares one version number — so a partial bump produces a combination that was never built or released as a set. IncludedevDependencies:@objectstack/cliis theosbinary that validates and builds your app, and a CLI left a release behind runs the previous release's author-time rules over your metadata. - Regenerate the lockfile, and commit it (
pnpm install/npm install/yarn install). This is the step that actually moves the code. A project pinned to exact versions moves because step 1 edited the pins; a project on the scaffold's default ranges (^17.0.0) moves entirely here. Read whatever peer-dependency warnings this prints — the--frozen-lockfileinstall your CI runs skips resolution and will never print them. - Check the two protocol declarations your app ships —
specVersioninobjectstack.manifest.json, andengines.protocolin yourdefineStackmanifest. Both name the protocol major, so a move inside one major leaves both alone, and a major boundary is where they change. Move them deliberately when you cross one; never edit them to silence a mismatch.
Then run the loop below — os validate before os build — because
moving the pins can move the gate.
What the boot does when the database disagrees
Moving the runtime can leave the physical database shaped for the previous version. What happens next depends on which deployment shape you run.
The standing production policy is hands-off. Under NODE_ENV=production,
automatic reconciliation is ignored and every divergence is warned about, on
the assumption that an operator runs os migrate deliberately:
os migrate plan # how the database has drifted, categorised safe / needs-confirm / destructive
os migrate apply # applies the loosening changes
os migrate apply --allow-destructive # the narrowing ones, once you have read the planThe artifact-pinned boot is stricter, because there is nobody at a terminal: a container simply comes up carrying a different artifact than the one that shaped the database. On that path the boot applies safe and needs-confirm drift itself, and refuses to start on destructive drift, printing every change and the command that resolves it. Nothing is skipped silently — "shrug and serve" is the state that gate exists to delete. The refusal happens before the HTTP port binds, so a refused boot is a boot that never served traffic.
A runtime move can still force metadata edits
The table at the top of this page says a runtime move does not touch your metadata. That is true in the narrow sense — nothing rewrites your source files — and it is not the same claim as no metadata work is needed. The difference has been measured.
An application repository was upgraded across a single minor, 17.2.0 →
17.3.0, by a reader holding only this documentation, the CHANGELOG.md files
inside the published npm tarballs, and the os CLI's own output. Moving the pins
and restarting was not enough:
os validaterefused the stack, and so didos build. The release registered one more author-time rule — the count printed byos validatemoved from 41 to 42 — and two objects that compiled on 17.2.0 stopped compiling. That is a build-stopping gate, not a warning.- A lint family widened onto surfaces it had not read before. In that app the count went from zero findings to 429.
- A constraint that had been declared but inert started being enforced, so nine identifier fields acquired a unique index on the next migration.
None of that is a protocol-major event. It does not appear in the conversion
window described below, and os migrate meta has nothing to replay for any of it
— see "Nothing to migrate" is not "you are
done". The only channel that carries
these is the release's own checklist, under Per-release
specifics.
After any runtime move, minor included, run os validate before you ship. A
stack that built clean on the release you are leaving is not evidence that it
builds clean on the one you are landing on.
When a platform move forces an app move
Each package declares the protocol major it was authored against, and the runtime checks that handshake first, before it loads anything:
manifest: {
// ...
engines: { protocol: '^17' },
}The load path then converts old metadata shapes to the canonical current shape as it reads them, emitting a deprecation notice per conversion. That window is exactly one major wide. Metadata authored against the previous major loads and runs; a shape retired one major further back is rejected, and the rejection carries the fix — the old spelling, the new spelling, and the command that lists every site to edit.
The practical consequence:
- One major behind — it runs. You will see conversion notices in the boot log. Migrate at your convenience.
- Two or more majors behind — the load rejects the retired shapes. The metadata half is no longer optional, and it is the next section.
The metadata app
Your app is versioned by you, upgraded by you, and shipped as a compiled artifact. Upgrading it across one or more protocol majors is one command followed by your normal build.
One command covers every major you skipped
os migrate meta --from 16 # the major your metadata was authored against--from is the major you wrote against, not the one you are going to. The
command replays every step between that major and this runtime's, in order, in a
single pass — so upgrading across skipped majors does not mean running it
once per major, and does not mean reading several release checklists and merging
them by hand:
| You ran | Steps it replays |
|---|---|
os migrate meta --from 16 | 17, then every later major this build carries a step for |
The chain reaches back to protocol 16 — that floor is a release-policy decision, not an accident of what still exists. Below it the command refuses with a message naming the floor rather than half-migrating you.
The floor was raised from 10 to 16. Earlier releases replayed the chain
from protocol 10, so --from 10 through --from 15 used to work and now
answer Cannot migrate from protocol N: the chain's support floor is 16.
Nothing half-migrates: the command refuses before it rewrites anything. If
your metadata is still authored against protocol 10–15, reach protocol 16 by
another path first — an older @objectstack/cli still carries those steps —
and then run the command above.
Useful flags:
| Flag | What it does |
|---|---|
--step | Report each major's hop separately, so a failure bisects to the exact major |
--all | Also list, in full, the manual changes whose surface the command proved absent from your stack — by default they are only counted. --json always reports them in todos and names them in absentTodos |
--out migrated.stack.json | Also write the migrated stack as a JSON snapshot |
--write | Write the mechanical changes into your source files, where each can be traced to one literal; list the rest with the reason (see below) |
--to 17 | Stop at an intermediate major instead of this runtime's |
--json | Machine-readable output, for CI or an agent |
It reads your source and no database. (Its sibling,
os migrate meta --stored, does the opposite — it rewrites one deployment's
stored metadata rows and reads no config. The two are mutually exclusive.)
What it does not do — read the output
Without --write, os migrate meta does not rewrite your source files. It
replays the chain over the loaded stack in memory and reports the diff; the
only file it writes is --out, a JSON snapshot — use it as the oracle you diff
against, never as the file you ship.
--write writes the mechanical changes into your .ts sources — only where
it can prove the site. A change is written when it traces to one object or
array literal in one project file: through define* calls and .create
factories, const bindings, relative imports and re-exports, and
Object.values() over a namespace import. Only that site's bytes change; a
renamed key keeps its value and its comments. Every other change — a value
built by a function call or an expression, a key a spread supplies, a binding
something else also uses, a file in a package — is listed with the reason it was
not written, for you to port by hand. The command then re-runs the chain over
the written files and restores them all unless it comes back with exactly the
changes it left. Commit first, and review the diff like any other change.
It also applies the mechanical changes only, and never guesses at the rest. Changes that cannot be converted losslessly are reported as structured to-dos for you to resolve — and a clean-looking run can still carry dozens of them. The command's output is the work list, not a receipt.
"Nothing to migrate" is not "you are done". This command's scope is the protocol chain, so on a move inside one major it has — correctly — nothing to replay, and says so:
$ os migrate meta --from 17
Nothing to migrate — the metadata is already canonical for this range.That sentence is true, and it is narrow. It is silent about author-time rules added inside the major, lint families that widened, defaults that flipped, declared constraints that started being enforced, and grants that changed meaning — none of which is a protocol conversion, and any of which can stop your build or change your runtime's behaviour. Measured on one 17.2.0 → 17.3.0 upgrade: the command printed exactly the line above and exited 0 while nine separate changes were live and unaddressed on that stack. Read it as the protocol chain is clean, then get the rest from Per-release specifics.
That split is the reason the per-major checklists below still matter: renames
and retired keys the tool enumerates for you; decisions it cannot make for you
stay yours.
The run also ends by naming the per-deployment data migrations that remain
(os migrate files-to-references, os migrate value-shapes, and friends) —
scoped to the field classes your metadata actually declares. It reads no
database, so that list is what is left to consider, never what this deployment
has already done.
The loop
os migrate meta --from 16 # 1. read the mechanical change list and the to-dos
# 2. apply the edits (--write writes the ones it can
# trace and lists the rest); resolve the to-dos
# and the checklist items below
os validate # 3. the gate — schema, CEL predicates, widget bindings
os build # 4. compile to dist/objectstack.json
# 5. ship the artifact; restart
os migrate plan # 6. reconcile the database to the new metadataStep 3 is the real gate: os validate runs the same checks as os build but
writes no artifact, which makes it the fast inner loop while you work through
the to-dos. Full command reference in the CLI
documentation.
Per-release specifics
Everything above is the mechanism. The contents of a given release — which keys were renamed, which defaults changed, which grant you now have to declare explicitly — live on that major's release page, written for app authors and compiled at release time.
Read the checklist for every release you are crossing, not only the one you
are landing on — and that means every minor inside a major as well as every
major boundary. A minor moves the runtime without moving your metadata's protocol
major, so os migrate meta has nothing to replay for it; that is exactly why the
minor's own accept-set tightenings, default flips and authorization narrowings
have no other channel to reach you than the page below.
| Release | Where its upgrade notes live |
|---|---|
| v17.4.0 | 17.4.0 — ⛔ checklist not written; machine-draft notes only |
| v17.3.0 | Upgrade checklist — 17.3.0 |
| v17.2.0 | Upgrade checklist — 17.2.0 |
| v17.1.0 | Upgrade checklist — 17.1.0 |
| v17.0.0 | Upgrade checklist — 17.0.0 |
| v16.0.0 | Upgrade checklist |
| v15.0.0 | Upgrade checklist |
| v14.0.0 | Upgrade checklist |
| v13.0.0 | Upgrade checklist |
| v12.0.0 | Upgrade checklist |
| v9.0.0 | Upgrade checklist |
Only the v17 line is broken out per release. Each of its three minors shipped
breaking changes under the repo's launch-window convention — which ships a
breaking change as minor rather than major, so the version number is not the
migration signal and the changelog entry is. Earlier majors' release pages carry
one checklist per major, which is what those rows link to.
The release notes overview summarizes what each major changed.
What the installed artifact tells you, without a second worktree
The release pages are written for a human. The same delta ships inside the
package, for your tooling: @objectstack/spec carries a spec-changes.json,
and from the release that follows 17.4.0 on it carries a release section
describing the release you actually installed — from → to at the package version, not at the protocol
major.
# What did the release I just installed change?
jq '.release | {fromVersion, toVersion,
added: (.added | length), removed: (.removed | length),
converted: (.converted | length), migrated: (.migrated | length)}' \
node_modules/@objectstack/spec/spec-changes.jsonadded and removed are the public exports that arrived and left, each one
named — "./ai: AgentSchema (const)", the entry point followed by the export
and its kind. converted and migrated are the ADR-0087 conversions and
semantic migrations first registered in that release. The same file's
perMajor records are unchanged and still answer the major-boundary question,
and so does aggregate — for its converted and migrated, which are derived
from the ADR-0087 registries across the whole from → to range.
⛔ But not for aggregate.added / aggregate.removed. Those come from the
same one-release export diff as the section above, not from the major range the
record is keyed by, so when they are filled the aggregate record carries a
surfaceScope naming the exact pair they span:
jq '.aggregate | {from, to, surfaceScope,
added: (.added | length), removed: (.removed | length)}' \
node_modules/@objectstack/spec/spec-changes.jsonsurfaceScope absent means that record claims no export diff at all and its
added / removed are empty — the registry-only shape. ⛔ Read that as "this
record does not say", never as "nothing was added between from and to",
which is the same rule the release section states for itself below. A release
whose aggregate arrays disagree with the two published tarballs, or carry no
surfaceScope, does not publish.
The os CLI reads the same section, so a CI job does not have to know the file
exists:
os validate --json | jq .specReleaseChangesThree properties worth relying on:
- The section describes a release, not a major. Every entry in
addedarrived intoVersionand every entry inremovedleft in it. That is the one question theperMajorrecords cannot answer, and it is the question a minor upgrade asks. - A missing section is not an empty one. The key is absent when the delta
could not be computed — a release published before this section existed, or
one whose predecessor shipped no export snapshot.
os validate --jsonreportsnullin exactly those cases. ⛔ Do not read an absent section as "nothing changed"; read it as "this artifact does not say". - It is verified against the artifacts before it ships. The release lane recomputes the delta from the previously published tarball and the one about to be published, and a release whose section disagrees with them does not publish. The numbers are as true as the two tarballs are.
What it does not tell you. The export surface is the shape of the API, not
its behaviour: a release can narrow what a value is allowed to be, or start
enforcing a constraint that was declared and inert, without adding or removing a
single export. A delta of 0 added, 0 removed is a real measurement of the
export surface and says nothing about the accept-sets behind it — the release
checklist above is still the record for those.
And it does not report withdrawals. All four arrays report what a release
added: converted and migrated list the ADR-0087 ids first registered
in it. An id that left the published chain between the two releases is in
none of them — converted: [] means "this release registered none", never
"none was withdrawn". ADR-0087 D4 names these four arrays and this section
carries exactly those four; to see a withdrawal, compare the aggregate
records of the two installed manifests (.aggregate.converted[].conversionId
and .aggregate.migrated[].migrationId) — an id present in the older one and
absent from the newer one was withdrawn.
The per-package changelogs are the exhaustive record
The release pages above are triaged, deliberately: a change is written up there when it can be reached from something an application ships or operates, and changes that move only the platform's own internals are left out. That triage is what makes a release page readable — 17.3.0 alone carries 862 changelog entries across the train — and it is also the page's limit.
The exhaustive record is the CHANGELOG.md shipped inside every published
package, so you already have a copy of the ones that matter to you:
less node_modules/@objectstack/spec/CHANGELOG.md
less node_modules/@objectstack/cli/CHANGELOG.md
# …one per @objectstack/* package your app depends onRead them whenever a release checklist does not explain what you are seeing, whenever you maintain a plugin or a driver against our internal contracts, and whenever you are crossing several releases at once. They are per-change, and they carry the reasoning and the migration for entries too narrow to reach a release page. On one measured documentation-only upgrade, these files — not the release pages — were where the answer to every question turned out to be, which is why this section is here rather than one line about two old majors. Curated notes for v10 and v11 were never backfilled at all, so for those two majors the changelogs are the only record there is.
What a major is allowed to change in the first place — which edits count as breaking, and the deprecation window a property must sit through before it can be removed — is the Backward Compatibility Policy.
Not this page
Upgrading an installed package — a template app or a third-party package already installed into a deployment — is a different operation with its own lifecycle (pre-check, plan, snapshot, execute, validate, commit or roll back). It is neither of the two halves above: it moves someone else's metadata inside your deployment. See the package upgrade protocol.