---
name: objectstack-upgrade
description: >
Upgrade an ObjectStack metadata project across a protocol major — run the
deterministic conversion chain, then work the semantic residue the chain
cannot express (intent choices, custom code on retired APIs, stale prose) to
a decision with the project's owner, and finish with a green `validate` plus
a human-readable upgrade report. Use when a project is on an older protocol
major and must move to the current one, when `@objectstack/spec` was bumped
across a major and metadata or code stopped parsing, when a parse or `tsc`
error quotes a `[REMOVED]` prescription, or when asked to "upgrade to v17" /
"升级到 v17" / "一键升级元数据项目". Do not use to author new metadata (the
domain skills cover that), or to reconcile physical database drift (that is
objectstack-platform's `os migrate plan` / `os migrate apply`).
license: Apache-2.0
compatibility: Requires `@objectstack/spec` and `@objectstack/cli` at the TARGET major; the chain replays from the spec's `MIGRATION_SUPPORT_FLOOR`. No network access required.
metadata:
author: objectstack-ai
version: "1.0"
domain: process
tags: upgrade, migration, protocol, major, retired-keys, tombstone, conversions, validate, report
---
# Upgrading an ObjectStack metadata project across a protocol major
> **preflight → mechanical chain → semantic residue → acceptance report**
## ⛔ The boundary — read this before the first command
**Never hand-write a rewrite the chain already applies.** If a key was renamed,
the conversion table knows the rename; running the chain attributes each rewrite
to a hop and proves the result is schema-valid. A hand-edit does neither, and it
silently diverges the moment the chain gains an entry.
**Never add a tolerant read to make old metadata load.** No `??` alias, no
"accept both spellings" branch, no coercion in the project's own code. A key was
retired because nothing enforced it or because exactly one spelling survives;
re-admitting the old one at the consumer is how the defect the retirement closed
comes back inside the customer's repo, where no gate can see it.
**Never resolve a residue item by guessing the owner's intent.** The residue
exists precisely because it is a business decision. The rule for when you decide
alone and when you ask is in [Layer 2](#decide-alone-or-ask) — it is the most
important paragraph in this skill.
**Never report "upgraded" without the acceptance artifacts.** `validate` green
plus the report is the machine criterion. Absent either, the status is
*in progress*, whatever the diff looks like.
**One `conversionId` per commit**, where the project's review culture allows it.
The id is the commit's subject line and its justification.
---
## Quickstart
```bash
# 0 · preflight — what major is this project on, what major is installed?
grep -rn "protocol" objectstack.config.ts package.json | head
node -p "require('@objectstack/spec/package.json').version"
# 1 · mechanical — replay the chain (reads the config, writes nothing but --out)
os validate > .upgrade/validate-before.txt 2>&1 || true # the control, kept
os migrate meta --from 16 --step
os migrate meta --from 16 --json > .upgrade/migrate.json
os migrate meta --from 16 --out .upgrade/migrated.stack.json
# 2 · semantic residue — harvest the prescriptions, then work each item
node -e "console.log(require('fs').readFileSync(require.resolve('@objectstack/spec/package.json').replace('package.json','spec-changes.json'),'utf8'))" > .upgrade/spec-changes.json
# 3 · acceptance — all four, not three
os validate # green (compare against validate-before.txt)
tsc --noEmit # tombstones type the retired keys as `never`
os migrate meta --from 17 --json # `applied` must be [] (see §3.3)
# → write .upgrade/REPORT.md (template in §3.4)
```
Everything below is the long form of those four steps.
---
## 0 · Preflight
### Establish the FROM major — do not guess it
`--from` is the protocol major the metadata was **authored** against, not the
one installed. Three sources, in order of authority:
1. **`manifest.engines.protocol`** in the stack config — the declared answer,
checked by the boot handshake. It is a **range**, not a version: every
scaffold and example writes the caret form (`engines: { protocol: '^17' }`),
and an exact `'16.0.0'` is only a range of one. Take the major it *targets* —
the floor: `'^17'` → `--from 17`, `'>=15 <18'` → `--from 15`, never the
upper bound.
2. **The last `@objectstack/spec` major the project ever installed** — read the
lockfile history (`git log -p pnpm-lock.yaml | grep -m5 '@objectstack/spec'`)
when the manifest is absent or stale.
3. **Ask.** A manifest that says 16 on a project last touched two years ago is a
claim, not a measurement. If (1) and (2) disagree, settle it — ⛔ never
default to the lower one: a default flip ([3.3](#33-validate)) stamps its
constraint onto a source already past that major.
Arriving several majors late is the designed-for case. `os migrate meta --from 10`
replays every step in order; there is no penalty for lateness and no requirement
to upgrade one major at a time.
### Make the work reviewable before you change anything
```bash
git checkout -b upgrade/protocol-17
mkdir -p .upgrade # every artifact this skill produces lands here
```
The `.upgrade/` directory is the deliverable's workspace: the machine outputs
(`migrate.json`, `migrated.stack.json`, `spec-changes.json`, `retired-names.txt`)
and the human output (`REPORT.md`). Keeping them in the repo for the review, and
deleting them on merge, is the usual arrangement — decide it with the owner.
---
## 1 · Mechanical layer — invoke the chain
### What `os migrate meta` actually does
```bash
os migrate meta --from 16 # replay 16 → current
os migrate meta --from 16 --step # per-hop checkpoint (bisect a failure)
os migrate meta --from 16 --to 17 # stop at a specific major
os migrate meta --from 16 --json # machine-readable result
os migrate meta --from 16 --out migrated.json # write the canonicalized stack
os migrate meta --from 16 apps/crm/objectstack.config.ts # pick the stack explicitly
```
It loads the stack config, normalizes it **without** applying the load-time
conversion pass, replays each major's conversions as a chain hop, and then
parses the result against the current schema to prove the output is valid. What
it prints:
- **`Applied N mechanical change(s)`** — one line per rewritten site, as
`path: from → to (conversionId)`. This is the diff, already attributed.
- **`N manual change(s) require your judgment`** — the chain's semantic entries
for the majors you crossed, each with a `why` and a `verify` line. These are
Layer 2's input, not a warning to dismiss.
- **`Migrated stack is schema-valid`** — or the warning that it is not yet,
which means a residue item is still blocking the parse.
- **Pending data migrations**, when the chain crosses into a major with
per-deployment data gates — see below.
### ⚠ The one fact that surprises every operator
**`os migrate meta` does not rewrite your source files.** It rewrites the
loaded stack *in memory* and reports the diff. The only file it writes is
`--out`, a JSON snapshot.
Porting the printed edits into the project's own sources is yours. Work from
that list, one `conversionId` at a time; use `--out` as the oracle you diff
against, never as the file you ship.
```bash
os migrate meta --from 16 --out .upgrade/migrated.stack.json
# then, after porting the edits into the real sources:
os migrate meta --from 17 --out .upgrade/recheck.json # should apply 0 changes
```
### Stored rows: rehydration replays the same conversions
A deployment's `sys_metadata` rows are the other subject. They are handled for
you at read time — the metadata loader and the ObjectQL plugin both pass each
stored row through `applyConversionsToStoredItem`, which replays the conversion
chain over a single item **including entries retired from the load path**. A row
written under protocol 16 therefore rehydrates in its protocol-17 shape without
anybody editing it.
Rehydration is a read-time projection: the rows on disk keep their old shape,
and you never hand-edit `sys_metadata`. To make it durable, run the stored pass
— read-only by default:
```bash
os migrate meta --stored # preview, writes nothing
os migrate meta --stored --type view --type object # narrow the pass
os migrate meta --stored --apply --yes # rewrite the rows
```
⛔ **`--yes` is not optional for you.** Without it `--apply` asks for
confirmation, and in any non-TTY — every agent session — it refuses instead,
exiting 1 with `confirmation_required`.
`--stored` takes no `--from`: a stored row carries its own history, so the
pass replays the whole chain. The authored-source flags and the stored-only
flags are mutually exclusive, and mixing them is refused rather than ignored.
### Data migrations are not metadata migrations
When the chain crosses into a major carrying per-deployment data gates, the run
ends by naming each one, what staying un-run costs, and that `--apply` is the
only writing mode. Read that output rather than re-deriving it. What it cannot
tell you is whether they have *run*: that happens against each deployment's own
database, and nothing in the metadata upgrade observes it.
⛔ **Carry every gate the run printed into the report as `pending`, by name.** A
gate nobody was told about is served by nobody.
---
## 2 · Semantic residue — the part that is yours
A conversion can rename a key, drop a dead one, or lift a value onto its
declared block. It cannot make a decision. Everything it cannot do lands here.
### 2.1 Harvest the instruction sources — all of them ship
The prescriptions are not on a docs site you have to be online for. They ship
inside the installed package. **Measured** against the published
`@objectstack/spec` file list:
| Source | Where, in a consumer project | Carries |
|:--|:--|:--|
| **Chain result** | `os migrate meta --from N --json` → `.specChanges` | The conversions + semantic entries for exactly the majors you cross. **Start here** — it is computed from the installed spec, so it can never be stale. |
| **D4 projection** | `node_modules/@objectstack/spec/spec-changes.json` | The same data for every major, offline: `perMajor[].converted` and `perMajor[].migrated`. |
| **Tombstone prescriptions** | `node_modules/@objectstack/spec/json-schema/**` and `src/**/*.zod.ts` | Every retired key's `[REMOVED] …` fix-it text, greppable. |
| **FROM → TO tables** | `node_modules/@objectstack/spec/CHANGELOG.md` | The per-retirement narrative, including the "what to write instead" table. This is why the package ships its changelog. |
| **The error itself** | Your parse / `tsc` output | The same prescription string, delivered at the moment you hit it. |
```bash
# every tombstone prescription the installed spec carries, deduped (`| wc -l` sizes it)
grep -rho '\[REMOVED\][^"]*' node_modules/@objectstack/spec/json-schema/ | sort -u
# protocol version, support floor, and this crossing's conversion / semantic counts
node -e "
const p = require.resolve('@objectstack/spec/package.json');
const j = require(p.replace('package.json','spec-changes.json'));
const to = parseInt(j.protocolVersion, 10);
const e = j.perMajor.find(x => x.to === to);
console.log(j.protocolVersion, 'floor', j.supportFloor, '| →', to + ':',
e.converted.length, 'converted,', e.migrated.length, 'semantic');
"
# the FROM → TO table for one retired key
grep -n -B4 -A20 'transform' node_modules/@objectstack/spec/CHANGELOG.md | less
```
⛔ **Never carry a remembered count or a pinned table into the report.** Every
number here is a reading of *this* install; measure it at the start of the
upgrade and again in the report.
> **Not reachable from a consumer project**, so do not send anyone there: the
> conversion and migration registries (`src/conversions/registry.ts`,
> `src/migrations/registry.ts`) and the platform repo's generated upgrade guide
> are **not** in the published package — only `src/**/*.zod.ts` is. Their
> consumer-facing projection is `spec-changes.json` and the chain's own `--json`
> output, which is exactly what the table above points at.
### 2.2 The three residue classes
**R1 · Intent choice — a key was retired with no single lossless target.**
The conversion drops the key (so the project parses) and, where it matters,
emits a notice naming the site. What the key was *for* still has to go
somewhere, and where is a business statement.
**R2 · Custom code calling a retired API.** The chain's semantic entries name
these: a service slot that no longer exists, an engine method that was removed,
a context field that was renamed. Metadata parses fine; the project's own
TypeScript is what breaks — or worse, keeps compiling while reading `undefined`.
**R3 · Prose that still teaches the old shape.** READMEs, comments, ADRs, seed
fixtures, and the project's own AI conventions file. Nothing fails, and the next
agent to read the repo re-authors the retired shape from it.
→ Moved verbatim to [references/examples-upgrade.md](./references/examples-upgrade.md) § 2.3 A worked R1 — the retired field-mapping `transform`.
### 2.4 Decide alone, or ask the owner
**Decide it yourself when all three hold:**
1. **The prescription names exactly one target.** The tombstone or conversion
summary gives a single FROM → TO, with the value unchanged.
2. **The evidence is in the project.** A grep in the repo settles it — the skill
that already owns the tool, the import that already exists, the field the
predicate already references.
3. **Being wrong fails a gate.** A mistaken choice breaks `tsc` or `validate`
rather than changing behaviour quietly.
**Ask the owner when any one of these holds:**
1. **Two or more real targets, and the choice is a business statement** — the
`transform` case above.
2. **The change is observable without a test failing** — security posture
(an authentication default), row visibility (an access predicate), retry
counts, retention. A wrong call here ships silently and is discovered by an
auditor.
3. **Capability has to be re-declared somewhere new**, so choosing wrong
*removes* a capability instead of breaking a build. Agent tooling that has to
move inside a specific skill is the canonical shape.
4. **The source is dead or undocumented in their repo** — nothing to decide
from. Say so; do not invent a rationale.
**How to ask.** One message, per item, carrying: the site (file and path), the
prescription verbatim, the options with what each costs, your recommendation and
why, and what you will verify once they choose. Never a bare "how should I
handle `transform`?" — that hands the reading work back to the person with the
least context about the diff.
**While you wait, do not stall the upgrade.** Park the item in the report as
`AWAITING DECISION`, keep the mechanical layer complete, and keep going. A
project can be schema-valid with open residue items; it just is not *done*.
### 2.5 Working an R2 — retired APIs in the project's own code
The chain's semantic entries are the search list. For each one, the surface it
names is a string you grep for in the project's own source:
```bash
# from the chain's own output — the surfaces it says it cannot fix for you
os migrate meta --from 16 --json | node -e "
let s=''; process.stdin.on('data',d=>s+=d).on('end',()=>{
for (const t of JSON.parse(s).todos) console.log(t.surface);
})"
# then, for each surface, search the project (not node_modules)
grep -rn "" src/ app/ --include='*.ts' --include='*.tsx'
```
Two traps that have cost real upgrades a lap:
- **A renamed context field keeps compiling.** When a read moves from one key to
another and the old key is simply absent afterwards, the code reads
`undefined` and every branch quietly takes its false path. Verify against a
real dispatch, not a fixture — invoke the path and assert the value observed
under the canonical key.
- **A rename is not always the fix.** If the old read was itself wrong, renaming
it migrates the defect rather than the code. Read the semantic entry's
`reason` before applying its `replacement`.
### 2.6 Working an R3 — the prose sweep
Run it last, once the shapes are settled, and run it over the whole repo:
```bash
# every retired key name the installed spec knows, as a search list
grep -rho '\[REMOVED\] `[^`]*`' node_modules/@objectstack/spec/json-schema/ \
| sed 's/.*`\(.*\)`.*/\1/' | sort -u > .upgrade/retired-names.txt
```
Then sweep the project's `*.md`, comments, seed fixtures, and its AI conventions
file. The conventions file matters most: it is what the next agent loads before
it writes anything, so a retired shape left there re-enters the codebase on the
next feature, long after the upgrade closed.
---
## 3 · Acceptance — what "upgraded" means
Four artifacts. Three are machine-checked; the fourth is the one a human reads.
### 3.1 Typed
```bash
tsc --noEmit
```
A retired key is not merely absent from the schema — it is declared as a
tombstone whose input type is `never`. Assigning anything to it fails to
compile, at the authoring site, before anything runs. A green `tsc` is therefore
positive evidence that no retired key survives in typed sources.
### 3.2 Parse-gated
The same tombstone rejects at parse time, and the rejection carries the
prescription rather than a generic "unrecognized key". This is the channel that
catches metadata `tsc` cannot see: JSON files, database rows, anything built at
runtime. You do not have to do anything to enable it — but you **do** have to
prove it is live for this project, because a schema that silently strips is
indistinguishable from one that accepts. See
[the reverse check](#reverse-check).
### 3.3 Validate
```bash
os validate # green is the criterion
os validate --strict # warnings become errors — agree with the owner whether this is the bar
```
`os validate` runs two passes: the protocol schema (where tombstones reject) and
the author-time rule set. Read the two separately — a rule finding about a
missing sharing model or an options-less choice field is a **pre-existing**
project-quality issue, not upgrade residue. Establish which is which by running
`os validate` **once before you start**, on the un-upgraded source, and keeping
that output as the control. Fixing the project's standing lint debt may be a
welcome side-effect, but it is not this upgrade, and it must not be reported as
part of it.
> ### ⚠ A green `validate` does NOT mean the chain has nothing left to do
>
> Some conversions are **migration-chain-only**: the loader deliberately does
> not apply them and no tombstone rejects the old shape, because the change is a
> default flip rather than a rename — auto-applying it would stamp a constraint
> onto sources that deliberately omit it. The 16 → 17 crossing has one:
> `field-required-notnull-explicit`, which writes the physical `storage.notNull`
> that `required` used to imply on its own.
>
> A project carrying only that shape validates **green** while the chain still
> has work. So `validate` green is necessary and not sufficient, and the
> criterion that closes the gap is the replay:
>
> ```bash
> os migrate meta --from --json # `applied` must be []
> ```
>
> Read `applied`, not the headline: `--to` defaults to the highest major this
> build carries a step for, which is one PAST the installed major for most of a
> release line, so `todos` carries the next major's semantic residue — real, but
> not this upgrade's business.
>
> Run both. A report that cites only `validate` cannot see this class at all.
→ Moved verbatim to [references/examples-upgrade.md](./references/examples-upgrade.md) § 3.4 The report — the human half.
### 3.5 Prove the gate is real, do not assume it
Make the parse gate fire once before you report it as evidence: take a value the
chain *would* have converted, put it back after migrating, and parse it.
```bash
# a stack that still carries a retired key, fed straight to the schema
os validate .upgrade/residue-probe.config.mjs
```
Write the probe as a **plain data literal** — ⛔ no `define*` call. `os validate`
loads the config without authored-source mode, and every `define*` helper is a
`Schema.parse()`, so a probe written the way a real config is written throws
*inside the load* and never reaches the gate you are trying to prove.
Predict the outcome **before** you run it, then record which one you actually
got — "it passed" is a different fact in every row:
| Outcome | What it means | Your acceptance evidence |
|:--|:--|:--|
| Rejected, error carries the fix-it text | A tombstoned key. Not "unrecognized key", not a deprecation label — the prescription *is* the error. | The refusal itself, quoted. |
| Accepted, and the chain rewrites it | A conversion with a live load-path acceptance window. | The chain's diff — not the parse gate. |
| Accepted, and `validate` stays green | A migration-chain-only conversion ([3.3](#33-validate)). | Only the replay from the target major sees this class. |
### 3.6 Stored rows: assert them, do not believe them
The four artifacts above cover the authored sources. For a **deployment**, the
stored pass is itself the assertion — run it read-only and branch on the exit
code:
```bash
os migrate meta --stored --json # 0 = every row canonical, 1 = work left
```
That is what makes "this deployment is on protocol N" a CI check rather than a
belief. Nothing gates on it having run; the read-time rehydration is still the
guarantee.
---
→ Moved verbatim to [references/examples-upgrade.md](./references/examples-upgrade.md) § The v17-canonical shapes, compiled.
---
## Failure modes
| Symptom | What it actually is | Fix |
|:--|:--|:--|
| `migrate meta` reports changes, but the files are unchanged | Working as designed — the command writes nothing but `--out`. | Port the printed edits into the sources, then replay from the target major to confirm 0 changes. |
| Replay from the target major still applies changes | The port is incomplete, or a source builds metadata at runtime from a shape the chain never saw. | Diff against `--out`; grep for the `conversionId`'s surface in code that constructs metadata dynamically. |
| `validate` green, but a feature silently stopped working | An R2 residue item: code reading a renamed key now reads `undefined`. | Exercise the path for real. A green parse says nothing about a `??` chain in the project's own code. |
| `validate` green from the start, so "there was nothing to upgrade" | A migration-chain-only conversion — no tombstone rejects it, so nothing complains. | Replay the chain anyway. `validate` green is necessary, not sufficient; see [3.3](#33-validate). |
| `validate` reports findings that have nothing to do with retired keys | The author-time rule pass, not the schema pass. | Diff against the pre-upgrade `validate` control. Pre-existing findings are not this upgrade's scope. |
| A retired key round-trips without error | The schema carrying it is not strict and the key is being stripped, or the key still has a live load-path window. | Determine which — the two need different acceptance evidence. See [the reverse check](#reverse-check). |
| `--apply` refused / stored-only flag rejected | `--apply`, `--yes`, `--force`, `--type`, `--database-url` mean something only with `--stored`. | Add `--stored`, or drop the flag; the authored-source chain has nothing to write to. |
| `MigrationFloorError` | `--from` is older than the chain's support floor. | Upgrade to the floor by an older route first; the floor is a release-policy boundary, not an oversight. |
Scripting the run instead of reading it? Every `--json` failure above carries a
stable `error` code to branch on — `stored_only_flag`, `missing_from_major`,
`unsupported_from_major`, `confirmation_required`, `database_busy` — and the
prose row is the same fact for a human.
## Cross-skill routing
- Authoring the corrected metadata — load the domain skill for the shape you are
fixing (**data**, **ui**, **automation**, **ai**, **api**, **i18n**); each one
routes on to **formula** for any CEL you rewrite.
- Runtime, plugin, and CLI questions the upgrade turns up — load **platform**.