references/boundaries.md
# Major boundary knowledge
```
LAST_VERIFIED: 2026-09-04
BOUNDARIES_COVERED: v3→v4, v4→v5, v5→v6
WITHIN_LINE_VERIFIED_FROM: sanity 4.0.0
WITHIN_LINE_VERIFIED_THROUGH: sanity 6.12.0
UPSTREAM_CHANGELOG_STARTS: sanity 3.91.0 ← see "the v3 line" below
```
**This file has two edges, and both matter.** Treat it as a cache with known limits, not as a complete list.
**Above the upper bound.** Anything released after `WITHIN_LINE_VERIFIED_THROUGH` is unknown here. Fetch the changelog for that remainder. This edge decays continuously: a new `sanity` minor ships most weeks, so assume it is stale and check rather than trusting the date above.
**Below the lower bound.** The boundary files cover every crossing from v3 onward. Their **within-line** content starts at 4.0.0. A project starting below that is inside the v3 line, which this file does not curate release by release, for the reason in the next paragraph.
So if the current version is below `WITHIN_LINE_VERIFIED_FROM`, **fetch what you can for the range from the current version up to that lower bound**, and read the rest of this note before reporting the result. A project on 3.76 crossing to 6.12 needs one fetch at the bottom, 3.76 to 4.0.0, and none at the top while the upper bound is current.
**The v3 line, and why its gap is different from an ordinary gap.** The upstream `CHANGELOG.md` in `sanity-io/sanity` begins at 3.91.0. There is no consolidated machine-readable changelog for 3.0.0 through 3.90.x in that repository, and the tagged trees for those releases do not contain the file either. So for a project starting mid-v3, the fetch instruction above **will come back thin, and thin is not clean.**
This is the one place where an empty result must never be reported as a finding. Say plainly that the release-by-release history for that range is not available from the changelog, that the v3 to v4 boundary itself is verified (section 1 of `boundary.v4.md`) while the interior of the v3 line is not, and put the residual risk in the human questions section. A project crossing the whole v3 line to v6 is dominated by the boundary changes and the curated v4, v5 and v6 line items anyway, so this gap is survivable when it is declared. It is only dangerous when it is silent.
The failure this whole note prevents: without the lower bound declared, an agent trusts the file for a range it never examined and reports "nothing in this boundary applies," which reads as a verified finding and is actually an unexamined gap. **Never assert that a range is clean unless it falls inside the two bounds above or you fetched it yourself and got real content back.**
Fetch instructions are in `version-lookup.md`.
Each item carries an applicability condition. If the condition cannot be evaluated from the repository, the item belongs in the human questions section of the report, not in the findings.
## Which files to read
**This file is the index. The boundary content lives in one file per major**, so a span that crosses one boundary loads one file instead of all of them.
| Read | When |
| --- | --- |
| `boundary.v4.md` | The span crosses v3 to v4, or lands anywhere in the v4 line |
| `boundary.v5.md` | The span crosses v4 to v5, or lands anywhere in the v5 line |
| `boundary.v6.md` | The span crosses v5 to v6, or lands anywhere in the v6 line |
| `deprecations.md` | Always. It is short and not tied to a boundary |
Each boundary file contains the crossing *into* that major plus the undeclared changes *inside* that major's line, because a span that crosses a boundary almost always lands somewhere in the line above it and needs both.
Worked example: a project on 5.31.1 going to 6.12.0 reads this file, `boundary.v6.md` and `deprecations.md`. It does not read the v4 or v5 files, and loading them would only invite findings that do not apply.
Studio v2 is out of scope. See the scope section of `SKILL.md`; detection and the stop behavior live there and in `detect.md`.
---
## Why minor releases matter as much as majors
Across the whole span from 4.0.0 to 6.12.0, exactly three releases carried a formal breaking-change footer: 4.0.0, 5.0.0 and 6.0.0. Everything else in the per-boundary files shipped in a minor or patch release as a removal, a deprecation, or a changed default, without a breaking-change label.
The sharpest example is in section 2 of `boundary.v4.md`. The `engines.node` field changed shape four times inside the v4 line, and one of those changes made a minor release refuse to install on the Node version its own boundary guide told you to be on. Nothing about that carried a breaking-change label.
This is the single most useful thing these files contain, because nobody reads forty sets of minor release notes. When you fetch the changelog for the remainder of the span, look for the same pattern rather than only checking major boundaries.
references/boundary.v4.md
# Boundary: into Studio v4
Covers the v3 to v4 crossing and everything inside the v4 line. Read this file only if the span crosses or lands in v4.
Within-line coverage for this file: **4.0.0 through 4.22.1**, verified 2026-09-04. The v3 line below 3.91.0 has no changelog source; `boundaries.md` explains why and what to report.
---
## 1. v3 to v4
A deliberately small boundary. The official guide describes it as requiring minimal, if any, application changes.
| Change | Applicability condition |
| --- | --- |
| **Node.js 20.19 becomes the minimum.** `engines.node` goes from `>=18` on the v3 line to `>=20.19` at 4.0.0. | Check `engines.node`, `.nvmrc`, CI runner images, and the build host. Applies if any run Node 18, or Node 20 earlier than 20.19. |
**The patch component is not a detail to round off.** The floor is 20.19, not 20. A project on Node 20.9 satisfies "Node 20" and still fails to install. Read the resolved Node version to its patch and compare it to the range, rather than comparing majors.
The boundary itself is otherwise additive. **But do not carry "everything else in v4 is additive" into the v4 line**, which is what the official guide's framing invites and what section 2 below exists to correct: the Node floor moves twice more inside the line, and several defaults change.
Source: `https://www.sanity.io/docs/help/v3-to-v4`
---
## 2. Within the v4 line
Shipped in minor releases. None of this carried a breaking-change label, and the boundary guide's "minimal, if any, application changes" framing does not cover it.
### The Node floor moves twice inside the line
This is the highest-value item in the section, because it breaks an upgrade that followed the boundary guide correctly. Verified two ways: the `engines.node` field read from the registry manifest for each release, then each range evaluated with `semver.satisfies` rather than by eye.
| Releases | `engines.node` | Accepts | Rejects |
| --- | --- | --- | --- |
| 4.0.0 to 4.3.x | `>=20.19` | 20.19 and newer | below 20.19, so Node 20.9 fails |
| **4.4.0 only** | `>=20.19 >=22.12.0` | **22.12 and newer, and nothing else** | 20.19, 21.x, 22.0 to 22.11 |
| 4.5.0 to 4.22.1, and the whole v5 line | `>=20.19 <22 \|\| >=22.12` | 20.19 through 21.x, plus 22.12 and newer | below 20.19, and **22.0 through 22.11** |
**4.4.0's range is an accident.** Two space-separated comparators are an AND in semver, so `>=20.19 >=22.12.0` collapses to `>=22.12.0` and the 20.19 clause is dead. A project on Node 20.19 installs 4.3.0 fine and cannot install 4.4.0. 4.5.0 fixed it.
Applicability: read the resolved Node version for local, CI, and the build host, then check it against the row for the stop being planned, not against the row for the target.
Two consequences worth stating in the plan:
- **A stop inside 4.4.0 is the wrong stop.** If a sequence lands there, move it to 4.5.0 or later. There is no reason to stop on the one release with a malformed range.
- **Node 22.0 through 22.11 is a dead band** from 4.5.0 all the way through the v5 line. It is easy to miss because it sits *above* the floor most people remember, so a team that upgraded Node to "22" to get ahead of the v6 requirement can land inside it and be rejected by releases that Node 21 installs fine. The fix is the same 22.12 move v6 needs anyway.
**Evaluate these ranges, do not read them.** `>=20.19 <22 || >=22.12` looks at a glance like it excludes Node 21, and it does not: `>=20.19 <22` accepts all of 21.x. Getting that backwards produces a confident, specific, wrong instruction to change a Node version that was already fine. One line settles it:
```sh
node -e "console.log(require('semver').satisfies('21.7.3','>=20.19 <22 || >=22.12'))"
```
### Defaults that changed
| Version | Change | Applicability condition |
| --- | --- | --- |
| 4.14.0 | **`scheduledDrafts` config option added, on by default.** | Applies to every project crossing this release. Relevant alongside the scheduled publishing deprecation in `deprecations.md`: a project that deliberately avoided scheduled publishing gets scheduled drafts switched on without asking. Confirm which behavior the team wants rather than assuming the default is fine. |
| 4.16.0 | **The `typography` plugin for Portable Text inputs was added and then disabled by default in the same release.** | Matters mainly as context for the v5 boundary, where the same behavior is turned **on** by default. See section 1 of `boundary.v5.md`. A project crossing 4.16 to 4.22 saw straight quotes preserved; the v5 bump silently reverses that. |
| 4.16.0, then 4.18.0 | **`enhancedObjectDialog` default flipped on, reverted, then made opt-out.** | Only if the config sets `beta.form.enhancedObjectDialog`. The end state matters more than the churn: 5.12.0 removes the option and makes the dialog unconditional, per section 2 of `boundary.v5.md`. So a project holding the flag at `false` should plan for the dialog arriving regardless. |
### Removals and type changes
| Version | Change | Applicability condition |
| --- | --- | --- |
| 4.12.0 | **`useRawPerspective` removed** in favour of `perspective`. | `grep -rn "useRawPerspective" src/` |
| 4.6.0 | The `'strike'` and `'strike-through'` decorator names were disambiguated in the types. | Applies to Portable Text schemas that declare a strikethrough decorator, and to any code matching on the decorator name. Grep for both spellings. |
| 4.5.0 | **`image` data marked as required for TypeGen.** | Only if TypeGen is in use. Generated types for image fields change shape, so expect new type errors in consumers, which are often in another repository. |
| 4.20.0 | Internal `ServerStyleSheet` usage removed. | No action here. The related item that needs a code change is the removal of the re-export from `sanity` at 5.2.0, in section 2 of `boundary.v5.md`. |
### Notes
**4.22.1 is the final v4 release**, published under the `maintenance-v4` dist-tag. It is the right intermediate stop before v5, and it is a patch above the last minor, so do not stop at 4.22.0.
references/boundary.v5.md
# Boundary: into Studio v5
Covers the v4 to v5 crossing and everything inside the v5 line. Read this file only if the span crosses or lands in v5.
Within-line coverage for this file: **5.0.0 through 5.31.2**, verified 2026-09-04.
---
## 1. v4 to v5
Published 2025-12-15. Three declared breaking changes plus one default flip.
### Requirements
| Change | Applicability condition |
| --- | --- |
| **React 19.2.2 or newer required**, for both `react` and `react-dom`. | Read resolved React version from the lockfile. If already 19.2.2+, this is satisfied and should be stated as satisfied rather than listed as work. |
If the project is on React 18, recommend running `sanity dev` on React 18.3 first to clear deprecation warnings, and enabling `reactStrictMode: true` in `sanity.cli.ts` to surface effect-cleanup and concurrent-rendering issues before the React bump rather than after.
### Declared breaking changes
| Change | Applicability condition |
| --- | --- |
| **TypeGen re-cases `snake_case` query names.** `PAGE_QUERY` now yields `PAGE_QUERY_RESULT` rather than `PAGE_QUERYResult`; `page_query` yields `page_query_result`. camelCase and PascalCase are unaffected. | Only if TypeGen is in use. Check for `sanity.types.ts`, a `typegen` block, or `sanity typegen` in scripts. Then grep for `snake_case` query variable names. Note that consumers are often in a different repository. |
| **TypeGen hoists shared types.** Repeated object shapes and document references become standalone named types instead of being inlined at each use site. This changes the shape of `schema.json`, not only the generated TypeScript. | Applies to anything consuming `schema.json` programmatically, including homegrown generators that emit types for other languages. Those will not appear in any dependency list, so ask rather than only grepping. |
| **`TypeGenerator.generateTypes()` in `@sanity/codegen` simplified.** Returns generated code directly; progress via an optional `reporter` callback. | Only if the project has tooling that wraps `@sanity/codegen` programmatically. |
### Default flip
| Change | Applicability condition |
| --- | --- |
| **Smart typography in the Portable Text editor is on by default.** Straight quotes become curly quotes, double hyphens become dashes, three periods become an ellipsis, and these characters are written into stored content. **This reverses the 4.16.0 default**, where the same plugin shipped disabled, so a project coming from anywhere in the v4 line is getting a behavior change rather than a new feature. | Applies to any project with Portable Text fields, which is most. Raise it whenever content is consumed by something that cannot be patched quickly, matches strings exactly, or renders with a font that may lack those glyphs. Disable globally or per field in the Portable Text editor configuration. |
---
## 2. Within the v5 line
Shipped in minor and patch releases. Most are not labelled breaking.
### Requires a code or script change
| Version | Change | Applicability condition |
| --- | --- | --- |
| 5.3.0 | `unstable_use*` hooks deprecated in favour of `useUnstable*`. | `grep -rn "unstable_use" src/` |
| 5.12.0 | **`beta.form.enhancedObjectDialog` config option removed.** The dialog is now unconditional. | Grep the config for `enhancedObjectDialog`. |
| 5.14.0 | `SANITY_STUDIO_AGENT_API_HOST` environment override removed. | Grep for the variable name across source and CI. |
| 5.15.0 | **CLI errors on unknown flags** instead of ignoring them. A typo such as `--datset` now fails the command. | Audit every `sanity` invocation in `package.json` scripts and CI config. |
| 5.18.0 | **`sanity start` deprecated in favour of `sanity preview`.** `start` was an alias of `preview`, so `preview` is the one-to-one replacement. **`sanity dev` is not equivalent**: `dev` runs a development server, `preview` serves an already-built production bundle. Recommending `dev` silently changes what the script does. Several commands also gained plural forms, with the singular kept as aliases. | Grep scripts and CI for `sanity start`. If a `start` script exists alongside a `dev` script, the likely intent was `preview`; confirm rather than assume. |
| 5.18.0 | **`sanity schema extract` always appends `schema.json`** to the path argument. | Applies if any invocation passes a full filename rather than a directory. |
| 5.18.0 | **Structure `sheetList` removed.** The bundled Sanity Create plugin was also removed from core. | `grep -rn "sheetList" src/` |
| 5.18.0 | `useTimeLineStore` deprecated; use the events store to retrieve deleted documents. | Grep for the hook name. |
| 5.19.0 | `menuButton`'s `placement` prop deprecated in favour of `popover.placement`. | Grep for `menuButton`. |
| 5.21.0 | `extractSchema` in `@sanity/schema` returns a proper object type for fieldless object types instead of `unknown`. | Only if the project consumes the extract API. |
| 5.2.0 | The `ServerStyleSheet` re-export from `styled-components` was removed from `sanity`. | Grep for `ServerStyleSheet` imported from `sanity`. |
| 5.8.1 | **`auth.loginMethod` strictly enforced.** `'token'` no longer falls back to cookie auth when another Studio on the same domain left session cookies; `'cookie'` now ignores localStorage tokens. | Applies if `auth.loginMethod` is set, if several Studios share a domain, or if a Studio is embedded in a Dashboard. |
### Behavior changes editors will notice
No code change required, but they belong in the plan so whoever supports editors is not surprised.
| Version | Change |
| --- | --- |
| 5.8.0 | Pasting a URL into a Portable Text field now automatically creates a link annotation. Raise this wherever the rendering surface may not handle the `link` mark. |
| 5.14.0 | A preview's `media` falls back to the schema type icon when `media` is omitted from `prepare()`. Return `media: null` or `media: false` to suppress. |
| 5.14.0 | Duplicating an array item regenerates `_key` for all nested items, not only the top level. Relevant to anything caching or diffing on `_key`. |
| 5.26.0 | `options.disableNew` is now actually enforced on fields where uploads were meant to be disabled. |
| 5.26.0 | The Studio routes to the first workspace a user can see, rather than the configured default when that default is hidden from them. |
| 5.29.0 | Array `initialValue` precedence changed: a parent field's `initialValue` now takes precedence over a child's inside `defineArrayMember`, with the child still filling omitted keys. |
| 5.31.1 | Documented caveat that becomes live once v6 flips the search default: under `groq2024`, preview fields resolved through a reference do not contribute to search matching or ranking. See section 1 of `boundary.v6.md`. |
### Notes
**5.31.2 is the final v5 release**, published under the `maintenance-v5` dist-tag. It is the right intermediate stop before v6. Note that it is a patch above the last minor, so a sequence that stops at 5.31.1 or at "the last 5.31 minor" is stopping one release early. Confirm the terminal release of a line from the registry rather than assuming the highest minor is it.
TypeGen reached general availability in 5.10.0, and automatic generation plus `--watch` arrived in 5.8.0. If a project is not yet using TypeGen, crossing v5 means it lands on the GA version with nothing to migrate, which is worth mentioning as an opportunity rather than a task.
references/boundary.v6.md
# Boundary: into Studio v6
Covers the v5 to v6 crossing and everything inside the v6 line. Read this file only if the span crosses or lands in v6.
Within-line coverage for this file: **6.0.0 through 6.12.0**, verified 2026-09-04. Anything released after 6.12.0 must be fetched; `boundaries.md` has the rule.
---
## 1. v5 to v6
Published 2026-06-11. A focused release: schemas, plugin APIs, configuration shape, and content APIs are unchanged.
### Requirements
| Change | Applicability condition |
| --- | --- |
| **Node.js 22.12 or newer required.** `engines.node` narrows from the v4 and v5 range of `>=20.19 <22 \|\| >=22.12` to a flat `>=22.12`. Node 20 reached end of life in April 2026. | Check `engines.node`, `.nvmrc`, CI images, and the build host. Affects build and development environments only, not the Content Lake or the browser bundle. |
Note for anyone arriving from the v4 or v5 line: **this is a genuine new requirement for Node 20.19 through 21.x**, which the whole v4 and v5 span accepted. The one band that was already rejected before v6 is Node 22.0 through 22.11, per section 3. A team sitting there has been out of range since 4.5.0 without necessarily knowing it, and the fix is the same move to 22.12.
### The change most likely to break a configuration
**`auth.providers` now replaces the built-in providers rather than appending to them, and `mode: 'append'` is no longer supported.**
Applicability: does `sanity.config.*` contain an `auth` block with `providers`?
This is the highest-severity item in the boundary because it affects login, and custom providers usually mean SSO or SAML. If the project has no `auth` block, it is unaffected, and saying so explicitly is valuable: it removes the scariest item from the plan.
Migration is to the callback form:
```ts
// before
auth: {providers: [{name: 'saml', title: 'SSO', url: '...'}], mode: 'append'}
// after
auth: {providers: (prev) => [...prev, {name: 'saml', title: 'SSO', url: '...'}]}
```
If the intent is to offer only the custom provider, the new default is already correct and the array form needs no change. Either way, recommend verifying the login screen with a real non-administrator account before production.
### Other declared changes
| Change | Applicability condition |
| --- | --- |
| **`enableLegacySearch` config option removed.** Use `search: {strategy: 'groqLegacy'}` instead. | Grep the config for `enableLegacySearch`. |
| **React strict mode on by default in development.** Opt out with `reactStrictMode: false` in `sanity.cli.ts`. | Applies to every project. It surfaces existing effect-cleanup and concurrent-rendering issues rather than creating new ones, so recommend leaving it on and fixing what it reports. |
| **Default search strategy changed from `groqLegacy` to `groq2024`.** Better performance on large datasets, deeper nested-content and Portable Text coverage, and wildcard, phrase, and negation syntax. Results, ordering, and counts shift because the query logic differs. | Applies unless `search.strategy` is already set. See below. |
**The specific search regression to test for:** under `groq2024`, preview fields resolved through a reference, such as `subtitle: 'author.name'`, no longer contribute to search matching or ranking. They did under `groqLegacy`.
Detect candidates with:
```sh
grep -rEn "(title|subtitle|description):\s*'[A-Za-z0-9_]+\.[A-Za-z0-9_.]+'" src/
```
Matches are candidates, not confirmations. Whether it matters depends on how editors search, which is a human question. `search: {strategy: 'groqLegacy'}` restores the old behavior as a temporary measure.
### Under the hood
**Vite 8.** Build times improved substantially in Sanity's internal testing, with smaller bundles from better tree shaking. The React plugin updates automatically.
Applicability: does `sanity.cli.ts` customize `vite`? If yes, this is the most likely source of a build failure on this boundary, and testing against the pre-release before committing is worth recommending. If no, this boundary loses its main build risk, and the plan should say so.
---
## 2. Within the v6 line
| Version | Change | Applicability condition |
| --- | --- | --- |
| **6.3.0** | **The Portable Text input dropped all legacy `data-slate-*` attributes, CSS classes, and non-`pt` data attributes from its editing DOM.** Migrate to the `data-pt-*` equivalents. | `grep -rn "data-slate"` across source, stylesheets, and test suites. **This is the highest-impact undeclared change in the v6 line and it fails silently.** Custom CSS stops applying and end-to-end selectors stop matching, with no error. |
| 6.2.0 | The markdown plugin's `config` prop deprecated in favour of `enabled`. | Only if `sanity-plugin-markdown` is configured with `config`. |
| 6.2.0 | Native browser autocomplete and autofill disabled on Studio form fields. | Informational. |
| 6.4.0 | `useDocumentVersionInfo` deprecated. | Grep for the hook. |
| 6.6.0 | **Invisible stega metadata stripped from text pasted into plain-text fields.** | Going forward, no action. Documents already containing stega characters need a one-time content migration using `stegaClean` from `@sanity/client/stega`. Only relevant if visual editing with stega is or was in use. |
| 6.6.0 | **`menuItems([])` in structure configuration is now honored**, so no default menu items are injected. | Applies if structure config uses `menuItems([])` and relied on the injected items appearing. |
| 6.6.0 | "Default sort" and "Default view" scoped to the individual document list rather than overriding every list of the same type. | Applies to structure customization that relied on the old spillover. |
| 6.6.0 | Opt-in table editing added to the Portable Text input, enabled with `plugins: {table: {enabled: true}}` on `components.portableText` plus a declared table schema. | Off by default, so no risk. Worth a note: do not enable it until every rendering surface can handle table blocks. |
| 6.7.0 | `Rule.uri({scheme})` respects a custom scheme when combined with other rules, so `mailto:` and `tel:` values are no longer wrongly rejected. | No action. Content that previously failed validation may now pass. |
| **6.9.0** | **`sanity build` and `sanity deploy` now honor `deployment.autoUpdates`**, which was previously ignored in some code paths. | Applies to every project with `autoUpdates` set. If a project set `false` but was receiving updates anyway, that stops. Verify which behavior the team expects. |
| 6.9.0 | `defineType`, `defineField`, and `defineArrayMember` preserve explicitly supplied optional properties on their return types. | Applies to any TypeScript project. Inferred types shift, so expect new type errors or newly redundant non-null assertions in schema-adjacent code. Usually quick to resolve. |
| **6.11.0** | **`useClient` deprecation narrowed to the no-argument call.** Calling `useClient()` without options warns; calling it with options, such as `useClient({apiVersion: '2024-01-01'})`, does not. | `grep -rEn "useClient\(\s*\)" src/`. The fix is to pass an explicit `apiVersion` rather than to stop using the hook. Worth doing regardless of the warning, because an implicit API version is its own latent problem. |
| 6.10.0 | The request-access screen was replaced with the shared `access-ui` package, which adds a `renderAction` slot. | Informational for most projects. Applies only if the access-request screen was customized or its markup was targeted by tests or CSS. |
| 6.12.0 | `displayName` assignments removed from `sanity` exports to unblock tree shaking. | Applies only to code or tests that identify components by `displayName`, including some snapshot and component-lookup patterns. Grep test suites, not just `src/`. |
| 6.12.0 | The mutator drops patch paths that cannot apply to the local document instead of throwing. | Informational. Relevant if custom code relied on the throw to detect a stale path. |
references/deprecations.md
# Cross-cutting deprecations
Not tied to a single boundary. Check these on any span.
---
## 1. Scheduled publishing, `studioHost`, and deploy hostnames
Not tied to a boundary, but worth checking on any span.
**Scheduled publishing** was deprecated in October 2025 and remains gated behind `scheduledPublishing: {enabled: true}`. If a project still uses it, migrating to Scheduled drafts or Content Releases is separate work and should be scoped separately rather than folded into a version upgrade.
**`studioHost` naming collision.** Two different things share this name, and conflating them causes unnecessary refactors:
- `studioHost` and `metadata.externalStudioHost` as **read** fields on the project information API response are deprecated, because a project can now host multiple Studios and a single field cannot represent them all. There is currently no public replacement for listing studio deployments programmatically. If a project reads either field from the projects API, that genuinely needs attention.
- `studioHost` as a **write** option in `defineCliConfig`, which tells `sanity deploy` which hostname to target, is a different surface. It is used throughout the hosting and deployment documentation, including the recommended pattern of configuring `projectId`, `dataset`, and `studioHost` via environment variables for building multiple Studios from one codebase. `sanity undeploy` also targets the host from CLI config.
Note that `studioHost` does not appear in the CLI configuration reference's property table while the deployment documentation uses it. That inconsistency is worth flagging to the reader as something to confirm, but it is not itself evidence that the CLI option is being removed. Describe the current documented state and do not speculate about future releases.
`deployment.appId` is **not** a replacement for `studioHost`. `appId` identifies an already-deployed Studio and exists to enable fine-grained auto-update version selection. The two are complementary, and `appId` provides little benefit while `autoUpdates` is `false`.
**Deploying and hostname assignment.** Hostname assignment happens through the CLI. A first `sanity deploy` prompts for a hostname interactively. `sanity deploy --url <hostname>` sets it non-interactively, `--title` names a newly created studio, and `--dry-run` reports what would be created without creating it, which is useful when rolling out across many deployments. Renaming, hiding from Dashboard, and removing a studio are available on the project's Studios tab in Sanity Manage.
references/detect.md
# Detection: establishing repository ground truth
Everything here is read-only. Run it before consulting any breaking-change list, so the list can be filtered against facts rather than assumptions.
## Contents
1. Locate the Studio and check the version floor
2. Resolve installed versions
3. Read the configuration
4. Resolve the TypeScript setup
5. Detect custom code surface
6. Detect deprecated and removed API usage
7. Detect environment and CI
8. Record sheet
---
## 1. Locate the Studio and check the version floor
A Studio is the directory containing `sanity.config.ts` (v3 and later) or `sanity.json` (v2). It may not be the repository root.
```sh
# v3+ Studio
find . -name "sanity.config.*" -not -path "*/node_modules/*"
# v2 Studio
find . -name "sanity.json" -not -path "*/node_modules/*" -maxdepth 3
# v2-era packages, which have no `sanity` package at all
grep -E '"@sanity/(base|desk-tool|default-layout|dashboard)"' package.json
```
**Check the floor before anything else. Studio v2 is a hard stop for this skill.** Any of these means v2:
- The resolved `sanity` major is below 3
- There is no `sanity` dependency and the project depends on v2-era packages such as `@sanity/base` or `@sanity/desk-tool`
- There is a `sanity.json` and no `sanity.config.*`
If so, stop and report per the scope section of `SKILL.md`. Do not continue through the rest of this file, and do not write a plan file.
If both `sanity.json` and `sanity.config.*` exist, the resolved `sanity` version wins. A stray `sanity.json` in a v3+ project is cleanup worth mentioning, not a reason to stop.
If you find several Studios, ask which one to plan for, or plan for each and say so. Monorepos with one Studio per tenant or per environment are common.
## 2. Resolve installed versions
Read the lockfile, not `package.json`. `"sanity": "^4.20.0"` tells you what was requested; the lockfile tells you what is installed, and the gap between them is often several minor versions of breaking changes.
**Every command in this section runs from the Studio directory, not the repository root.** The Studio is frequently a subdirectory, and the `node_modules` and lockfile that matter are the ones governing *it*. Set the directory from the path chosen in step 1, and do not auto-pick when step 1 found more than one Studio:
```sh
STUDIO=studio # or `.`, or apps/studio, packages/cms, whatever step 1 found
cd "$STUDIO"
```
Two things to check before trusting a path here. In a pnpm or Yarn workspace the lockfile usually sits at the **workspace root** while `node_modules` is local to the Studio, so the two facts come from different directories. And a Studio with no lockfile of its own is being resolved by a parent manifest, which is itself a finding worth reporting.
```sh
# Fastest reliable read, if node_modules exists
cat node_modules/sanity/package.json | grep '"version"'
# Otherwise, from the lockfile governing this directory (may be at the workspace root)
grep -A2 '"sanity@' package-lock.json | head -20 # npm
grep -A2 "^ sanity@" yarn.lock | head -20 # yarn
grep -A2 " sanity@" pnpm-lock.yaml | head -20 # pnpm
# Whole tree at once, if installed
npm ls sanity @sanity/vision @sanity/ui @sanity/icons @sanity/client styled-components react react-dom
```
Record the resolved version of: `sanity`, every `@sanity/*` package, `react`, `react-dom`, `styled-components`, and every third-party plugin (anything matching `sanity-plugin-*` or that appears in the config's `plugins` array).
Note the package manager and its version. It determines whether deduplication is `overrides`, `resolutions`, or `pnpm.overrides`, and pnpm's stricter resolution changes how duplicate majors behave.
### Report duplicates that already exist
**Any package appearing more than once in the tree is a finding to report now, not only something to verify after the upgrade.**
Two majors of a shared package such as `@sanity/ui`, or a plugin dragging in `@sanity/util` and `@sanity/types` from a different Studio major than the installed core, means the tree is already in the state that produces duplicate-context errors and unstyled components. That is worth telling the reader before they change anything, because it may explain bugs they already have, and because fixing it first removes a variable from the upgrade.
Read the dependency tree output rather than only the top-level list. Nested entries are where this shows up:
```sh
pnpm why --json @sanity/ui @sanity/icons @sanity/client @sanity/util @sanity/types react styled-components
# npm
npm ls --all --json @sanity/ui @sanity/icons @sanity/client @sanity/util @sanity/types react styled-components
```
**Use the package manager's own output, and treat the grep below as a last resort.** `pnpm why` and `npm ls` parse the lockfile with the tool that wrote it; a regex does not. It will keep working when the lockfile format changes and it cannot confuse one package's name for a substring of another's. `--json` also makes the result parseable rather than scraped, which matters when you need the parent that pulled a version in, not just the version.
Reach for the lockfile directly only when neither is available: no `node_modules`, or the matching package manager is not on the machine. Then **anchor the package name and keep prerelease suffixes**, or you will report findings that do not exist. A loose pattern turns `babel-plugin-styled-components@5.1.36` into a styled-components v5 duplicate, `@sanity/ui@5.0.0-alpha.3` into a nonexistent stable 5.0.0, and `@sentry/react@8.55.2` into a React version:
```sh
grep -oE "[/'\"]@?[a-zA-Z0-9@/._-]+@[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?" pnpm-lock.yaml \
| sed -E "s|^['\"/]+||" \
| grep -E "^(@sanity/(ui|icons|client|util|types)|styled-components|react)@" \
| sort -u
```
Put what you find in the report as a finding with the specific versions and which dependency pulled each one in. Do not reduce it to a generic "check for duplicates after installing" line.
This is the **observed** tree, so it is a finding. What the tree will look like after the upgrade is a prediction, and `package-coupling.md` covers why a planner must not assert it.
### Watch for more than one dependency tree
A repository can contain several installs, and they can disagree. Common shapes: a Studio in a subdirectory with its own lockfile while the repository root has another; a root manifest that depends on `sanity` for linting; a monorepo with a Studio and a frontend using different package managers.
When this happens:
- Record which tree you are planning for, and say so in the report header
- Record the sibling trees' `sanity` versions too, because two different Studio majors resolving in one repository will confuse editor TypeScript and can mislead anyone reading versions from the wrong place
- Take every version fact from the target tree's lockfile. A version read from the wrong tree looks authoritative and is simply about a different install
- If a deployment pipeline builds from a different tree or a different manifest than the one you are planning for, that mismatch is a finding on its own
## 3. Read the configuration
Read these in full, not by grep. They are short and they answer many questions at once.
- `sanity.config.ts` (or `.js`, or the workspace array if there are several)
- `sanity.cli.ts` (or `.js`)
- `package.json`, including the `scripts` block
- `sanity.json` if this is v2
From `sanity.config.*`, record whether each of these is present:
| Look for | Why it matters |
| --- | --- |
| `auth` block, `auth.providers`, `auth.mode`, `auth.loginMethod` | Highest-severity v6 change; affects login |
| `search` or `enableLegacySearch` | Whether the v6 search default change is already overridden |
| `plugins` array contents | Each entry needs a compatibility check |
| `document.actions` or `document.badges` | Custom actions need testing against newer document contexts |
| `tools` | Custom tools often reach into Studio internals |
| `form` or `beta.form` | Some of these options were removed |
| `scheduledPublishing` | Deprecated; separate migration |
| Multiple workspaces | Workspace routing behavior changed |
| `basePath` | Interacts with the CLI-level `basePath` |
From `sanity.cli.ts`, record:
| Look for | Why it matters |
| --- | --- |
| `deployment.autoUpdates` | Determines the whole auto-update section, and whether the 6.9.0 enforcement change matters |
| `deployment.appId` | Needed for version-channel selection |
| `studioHost` | See the trap note in `package-coupling.md` |
| `vite` | Custom Vite config is the most likely v6 build failure |
| `reactStrictMode` | Whether the v6 default is already set |
| `typegen`, `schemaExtraction` | Whether codegen is wired up |
| Values from a function or env vars | Signals multi-tenant or multi-environment deploys |
If config values come from a helper function or environment variables, read the helper. A templated `studioHost` or a per-tenant `projectId` means there are multiple deployments to upgrade, which belongs in the plan.
## 4. Resolve the TypeScript setup
```sh
cat tsconfig.json
```
You need the effective `moduleResolution`. Recent `@sanity/*` packages resolve types through `exports` and drop `typesVersions`, so legacy `node10` resolution cannot resolve subpath types at all.
If `tsconfig.json` has an `extends` pointing outside the repository, **follow it if you can and say so if you cannot.** An unresolved `moduleResolution` is a genuine blocker for any plan involving subpath imports, and it belongs in the human questions section by name rather than being quietly skipped.
```sh
# Resolves the full inherited config, including anything reached via `extends`.
# `--no-install` is required: plain `npx tsc` downloads TypeScript when it is not
# already present, and this skill does not install anything.
# Select the fields rather than truncating: `head` will cut a long config off
# above `moduleResolution` and leave you reporting it as unset.
npx --no-install tsc --showConfig 2>/dev/null \
| grep -E '"(moduleResolution|module|target|jsx|strict|paths)"'
```
If that prints nothing, TypeScript is not installed in this directory. Read `tsconfig.json` and follow its `extends` chain by hand instead of installing anything, and if the chain leaves the repository, say so rather than guessing. An unresolved `moduleResolution` belongs in the human questions section by name.
Also record whether `typescript` and `@types/react-dom` are actual devDependencies or are being inherited from a workspace root.
## 5. Detect custom code surface
The size of the upgrade is mostly determined by this. Count, then look at examples.
```sh
# Which Sanity UI primitives are used, and how much
grep -rn "from '@sanity/ui'" src/ --include=*.ts --include=*.tsx | wc -l
grep -rn "from '@sanity/ui'" src/ --include=*.ts --include=*.tsx
# Icons: barrel imports break when @sanity/icons drops them
grep -rn "from '@sanity/icons'" src/ --include=*.ts --include=*.tsx
# Custom Studio components
grep -rln "defineField\|defineType\|defineArrayMember" src/ | wc -l
grep -rn "components:\s*{" src/ --include=*.ts --include=*.tsx | head -20
# Reaching into Studio internals: highest risk, no compatibility guarantee
grep -rEn "sanity/_internal|sanity/_singletons" src/
# Portable Text editor DOM coupling, in source, styles, and tests
grep -rn "data-slate" . --include=*.ts --include=*.tsx --include=*.js --include=*.css --include=*.scss --include=*.styl
grep -rn "data-slate" e2e/ tests/ cypress/ playwright/ 2>/dev/null
```
If `sanity/_internal` or `sanity/_singletons` imports exist, raise it prominently. Internal APIs carry no compatibility guarantee and change without changelog entries. A multi-major upgrade over internal APIs is a different, riskier project, and the honest recommendation may be to get off them first.
Look at one or two representative custom components rather than all of them. One example reveals the patterns in use, which is what determines whether a rename is twenty edits or four hundred.
## 6. Detect deprecated and removed API usage
```sh
grep -rEn "unstable_use|useTimeLineStore|useDocumentVersionInfo|enableLegacySearch|enhancedObjectDialog|sheetList|useClickOutside|useElementRect|useForwardedRef|useArrayProp|ConditionalWrapper" src/ sanity.config.* sanity.cli.*
# Sanity UI props removed in its v4
grep -rn "space={" src/ --include=*.tsx | wc -l
grep -rEn "<Grid[^>]*(columns|rows)=" src/ --include=*.tsx
grep -rEn "focusFirst|focusLast|boundaryElement|allowedAutoPlacements" src/ --include=*.tsx
# Previews that resolve through a reference: relevant to the search strategy change
grep -rEn "(title|subtitle|description):\s*'[A-Za-z0-9_]+\.[A-Za-z0-9_.]+'" src/
```
## 7. Detect environment and CI
```sh
node -v
cat .nvmrc .node-version 2>/dev/null
grep -n '"engines"' -A4 package.json
# Sanity CLI invocations: newer CLIs reject unknown flags instead of ignoring them
grep -rEn "sanity (start|dev|build|deploy|undeploy|schema|schemas|typegen|dataset|datasets|graphql|tokens|exec)" \
package.json .github/ .gitlab-ci.yml Dockerfile* 2>/dev/null
```
The Node version that matters is the one in CI and on the build host, not the one on this machine. If you can only see the local version, say so and ask.
Also check whether the Studio shares a repository with other applications. If it does, a Node bump should be scoped to this project's CI job rather than the whole runner, and hoisted React may be shared with an app that pins a different version.
## 8. Record sheet
Carry this forward into the report. If a value is unknown, write "unknown" rather than guessing, and add it to the human questions.
```
Studio path:
Tree planned for: path to the lockfile every version below came from
Other trees in repo: paths + their resolved `sanity` versions
Config format: sanity.config.* (v3+ required; v2 is a hard stop)
Workspaces: names, count
Multiple deployments: yes/no, how they differ
sanity (resolved):
@sanity/* (resolved):
Third-party plugins:
react / react-dom:
styled-components:
Duplicate packages:
Package manager:
Pre-existing duplicates: package@version pairs + what pulled each in
Node: local / CI / build host
engines.node:
moduleResolution: value, or unresolved and why
typescript a devDep: yes/no/inherited
auth block: absent | providers | mode | loginMethod
search config: absent | strategy
autoUpdates: true | false | unset
appId: set | unset
studioHost: set | unset
custom vite config: yes/no
reactStrictMode: set | unset
@sanity/ui import sites: count
@sanity/icons imports: count, barrel or subpath
custom components: count + kinds
document actions/badges: yes/no
custom tools: yes/no
structure customization: yes/no
internal API imports: yes/no ← raise prominently if yes
data-slate references: yes/no ← raise prominently if yes
deprecated APIs found:
CLI invocations in CI:
```
references/package-coupling.md
# Package coupling
Most upgrades that fail do not fail on the `sanity` package. They fail on the packages around it. A changelog cannot warn about this, because it depends on which packages a given project happens to depend on directly.
## Contents
1. The match-core rule
2. Lockstep packages
3. Shared packages with their own majors
4. Plugins
5. Missing peer dependencies
6. Duplicate detection and dedupe
7. Environment floors
---
## 1. The match-core rule
**For any `@sanity/*` package the project depends on directly, match the version that the target `sanity` release depends on, rather than that package's own `latest`.**
```sh
npm view sanity@<target-version> dependencies --json
```
The `sanity` package pulls in a set of `@sanity/*` packages as regular dependencies. When a project also depends on one of them directly, and takes `latest` for it, the two can land on different majors in the same tree.
For packages that carry React context or a styled-components theme, that is not a cosmetic problem. Two majors of a theming package in one tree produces missing-or-duplicate context errors and unstyled components, and those surface as runtime bugs that look unrelated to the upgrade. The Sanity docs have a dedicated troubleshooting page for exactly this failure, which is a good indication of how often it happens.
**The one case where taking a newer major is correct** is when the target `sanity` release itself depends on that newer major. Check first, then decide. This ordering matters: it turns a guess into a lookup.
**Watch for a major dependency bump inside a minor release of `sanity`.** Compare the target's dependency ranges against the previous minor. If a shared package crossed a major between two adjacent minors, then choosing the later one silently adds a second migration to the change set. Flag it and offer the earlier release as an option that keeps the two migrations separable.
## 2. Lockstep packages
`@sanity/vision` tracks the `sanity` major and declares a peer dependency on it. Pin it to the same version as `sanity` and move both together. A vision version from the previous major will produce a peer dependency error against the new core.
Treat any first-party package that declares a peer on `sanity` the same way: read its peer range, and pick a version whose range accepts the target.
## 3. Shared packages with their own majors
These are versioned independently of `sanity`, so they need explicit checks. Query the registry for current versions rather than relying on any list.
### `@sanity/icons`
The change that requires code edits: **a major of this package removed the per-icon barrel exports.** Each icon moves to its own subpath, `'@sanity/icons/Some'`. The root entry retains a dynamic `Icon` component and an `icons` map, with each icon behind lazy loading.
**How that removal presents is the part that fools a checker, and it changed inside the major.** Verified against the shipped `dist/index.d.ts` on 2026-09-04:
- At **5.0.0** the icon names are simply gone from the root entry. A barrel import fails to resolve.
- From **5.1.0** onward the names are back in the root `export {...}` list, each declared as `declare const SomeIcon: never` carrying `@deprecated <name> is no longer exported from the @sanity/icons root entry (removed in v5) - the icon itself still exists. Import it from its own subpath instead.`
So on 5.1.0 and later the import **resolves**, and a check that only asks "is this name exported from the root?" gets back yes. It is still removed. The value is typed `never`, the runtime export is gone, and the icon does not render. Passing it as a schema `icon` or writing `<SomeIcon />` yields a confusing type error at best and a missing icon at worst.
**Never conclude that a barrel import is fine because the name appears in the export list.** Read the declared type. This is the same `never`-stub technique `@sanity/ui` v4 uses for removed props, described below, and it is worth treating as this vendor's house deprecation pattern rather than a quirk of one package.
An earlier major made the package ESM-only, raised its Node floor, and dropped `forwardRef` in favour of React 19's ref-as-prop model.
Detection is a grep for barrel imports. The edit is mechanical, and the payoff is real bundle savings. Subpath type resolution requires `moduleResolution` of `node16`, `nodenext`, or `bundler`.
A project can stay on an older major if its React peer range still allows it, but doing so leaves two copies of the package in the tree when core depends on the newer one. Matching core is usually the better recommendation.
### `@sanity/ui`
A major of this package is a substantial migration in its own right, and its scope depends entirely on how many components a project touches. Count the import sites before sizing it.
Known shape of the v3 to v4 migration:
- ESM-only; no CommonJS consumers
- Raised floors for Node, React, and `styled-components`
- `moduleResolution` must be `node16`, `nodenext`, or `bundler`, because types resolve through `exports` and `typesVersions` is gone
- `@sanity/ui/styles.css` must be imported manually at the application entry point; styles are no longer injected automatically. Inside a Studio the core package loads the stylesheet, so custom components usually inherit it, but this is worth verifying visually rather than assuming
- Heavier components moved behind subpath entry points: toast, popover, tooltip, menu, autocomplete, breadcrumbs, and code each have their own subpath. The theme entry point did not move
- `space` renamed to `gap` on a list of layout and control components. This is typically the most frequent single edit
- Grid `columns` and `rows` became `gridTemplateColumns` and `gridTemplateRows`; the `column` and `row` prop families became their `gridColumn` and `gridRow` equivalents
- Several hooks and one component are removed. All of them ship as `never` stubs whose `@deprecated` text names the replacement, verified in 4.0.7's `dist/index.d.ts` on 2026-09-04: `useClickOutside` → `useClickOutsideEvent`, `useElementRect` → `useElementSize`, `useForwardedRef` → React's own `useImperativeHandle`, `useArrayProp` → `Array.isArray(x) ? x : [x]` inline, and `ConditionalWrapper` → inline the conditional wrapping. **Read the `@deprecated` text rather than this list**: it is generated from the package, it names the replacement precisely, and it cannot go stale the way a curated list can
- Tooltip and popover keep closed content mounted but hidden. A popover whose content renders another popover must gate the recursion on its open state, or the hidden tree recurses indefinitely. Tests asserting a tooltip is absent from the DOM need to assert it is not visible instead
**The failure mode is the important part to convey:** removed props and hooks are typed `never` and *ignored at runtime*. A missed `space` produces a confusing type error and silently lost spacing rather than an unknown-prop error. So the build will not catch everything, and the plan should say to visually diff the affected components.
Everything in the list above was verified against the shipped `@sanity/ui` 4.0.7 package on 2026-09-04: `type: module`, no `typesVersions`, `engines.node >=22.12`, peers `react ^19.2` and `styled-components ^6.1`, a `./styles.css` subpath, the seven component subpaths plus `./theme`, and `space?: never` present in the prop types. Re-confirm against the package and its migration guide at generation time rather than treating the list as current.
**Do not read the `never` stubs as evidence that these still work.** They are the same pattern as the `@sanity/icons` root exports above, and the general rule for both is in section 6 of `plugins.md`.
### `styled-components`
Match the range the target `sanity` release declares as a peer, and check what core depends on. A mismatch here is usually benign but can produce duplicate theme contexts.
### `groq` and `groq-js`
**Do not assume `groq` versions in lockstep with `sanity`. It does not.** Its major numbers happen to look similar, which makes the assumption easy and wrong.
Two separate packages with different roles:
- **`groq`** is the tagged-template helper that produces query strings, and the package TypeGen reads queries from. It is versioned independently and its `latest` can sit several minors behind the Studio's.
- **`groq-js`** is the query evaluation library, and it is what recent Studio majors actually depend on. Check which one the target release names.
So: query the registry for `groq` separately, and check what the target `sanity` release depends on rather than reusing the Studio's version number.
```sh
npm view groq dist-tags --json
npm view sanity@<target-version> dependencies --json | grep -i groq
```
Recommending `groq@<studio version>` when that version was never published produces an install failure, which is a worse outcome than a stale recommendation. See the existence check in `version-lookup.md`.
If the project uses the `groq` helper only to build query strings, aligning its version is tidiness rather than a requirement, and it can be handled separately from the Studio bump.
### `@sanity/client`
Frequently a direct dependency of a backend or frontend service rather than the Studio, in which case it is in a different tree and does not constrain the Studio upgrade at all. Say so if that is the situation, rather than implying a coupling that does not exist.
If it *is* a direct Studio dependency, apply the match-core rule.
If a service depends on it separately and a new major is available, treat that as its own change with its own risk profile and its own release window. A client major in a service that fronts production traffic has a different blast radius from a Studio upgrade that affects editors, and bundling them makes failures impossible to attribute. Note also that the `sanity` package may still depend on an older client major than the client's own `latest`; that is normal and is a useful maturity signal, not a constraint.
## 4. Plugins
Plugins get their own pass. See `references/plugins.md`, which covers enumeration, ownership determination from the registry `repository` field, the compatibility checks, and what to do when a plugin blocks the upgrade.
The one item that belongs in both places, because it is a coupling problem rather than a plugin problem: **a plugin's own `@sanity/*` dependency can cross a major relative to what the target `sanity` depends on**, putting two majors of a shared package in one tree. A *patch* release of a plugin can do this, so check the specific version you plan to install rather than only `latest`. When it happens, look for an earlier patch that still matches core and pin to it.
## 5. Missing peer dependencies
List every plugin's `peerDependencies` and verify each one appears in the project's own `package.json`.
A missing peer often works by accident through hoisting, and then stops working across a major bump when the tree is rebuilt. Adding it explicitly before the upgrade is cheap and removes a confusing failure mode.
Related: if the project currently installs with relaxed peer resolution, such as `--legacy-peer-deps`, then peer warnings have stopped being a usable signal. That matters, because peer ranges are the main mechanism that would otherwise tell the reader a plugin is incompatible with the new core. Recommend resolving the underlying conflicts and removing the flag as part of the upgrade, and note it as a risk if they keep it.
## 6. Duplicate detection and dedupe
**Report duplicates you observed. Never predict duplicates you did not.**
A read-only planner cannot resolve a dependency graph, so it cannot know what the tree will contain after a change. Declared dependencies do not determine the outcome: transitive dependencies and the other shared packages defeat any set assembled by matching one package's declared major. Predicting a clean tree and being wrong discredits the verified findings around it.
So there are two different statements, and the plan must not blur them:
- **Observed, from the current tree:** "`@sanity/ui` resolves to 1.9.3 and 2.14.0 today, pulled in by X and Y." This is a finding. State it.
- **Expected, after a change:** "a single `@sanity/ui` 4.0.x is expected here." This is a prediction. Label it unverified and attach the command.
```sh
# Observe the current tree (a finding)
pnpm why @sanity/ui @sanity/icons @sanity/client @sanity/util @sanity/types react
npm ls --all @sanity/ui @sanity/icons @sanity/client react
# Settle a prediction before committing to a stop
pnpm install --lockfile-only && pnpm why @sanity/ui @sanity/icons @sanity/client
```
More than one major of `sanity`, `@sanity/ui`, or `react` in an observed tree is a finding. Force deduplication with `overrides` for npm, `resolutions` for Yarn, or `pnpm.overrides` for pnpm, and note that the override itself needs verifying by a resolve.
**When reading a lockfile directly, anchor the package name and keep prerelease suffixes.** A loose pattern reports `styled-components@5.1.36` when the entry is `babel-plugin-styled-components@5.1.36`, reports `@sanity/ui@5.0.0` when the entry is `5.0.0-alpha.3`, and turns `@sentry/react@8.55.2` into a React version. All three produce confident wrong findings:
```sh
grep -oE "[/'\"]@?[a-zA-Z0-9@/._-]+@[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?" pnpm-lock.yaml \
| sed -E "s|^['\"/]+||" \
| grep -E "^(@sanity/(ui|icons|client|util|types)|styled-components|react)@" \
| sort -u
```
A useful signal for pre-existing duplication: **unnecessary type casts on plugin calls in the config.** If the config casts plugin return values to the plugin options type, that usually means two copies of the `sanity` types are resolving, so the type returned by the plugin is not structurally identical to the one the config expects. Worth resolving during the upgrade rather than carrying forward, and a good test afterward is whether the casts can be deleted.
In a monorepo, also check whether React is hoisted to the workspace root and shared with other applications. If another application pins a different React version, satisfying the Studio's floor may require workspace-scoped resolution rather than a root bump.
## 7. Environment floors
Collect these from the target release rather than from memory:
```sh
npm view sanity@<target-version> engines peerDependencies --json
```
`engines.node` applies to development servers and build environments, not to the Content Lake or the browser bundle. Say that explicitly, because teams often assume a Node requirement implies a production infrastructure change.
If the Studio shares a repository or CI pipeline with other applications, recommend scoping the Node change to this project's job rather than the whole runner. A global runner bump can disturb other build toolchains that have their own supported ranges.
references/plugins.md
# Plugins
Plugins are the most common thing that actually blocks a Studio upgrade. The Studio itself usually bumps cleanly; a plugin whose peer range does not accept the target major stops the whole job.
## Contents
1. Verify every plugin, assume nothing
2. Enumerate what is installed
3. Determine ownership
4. Assess compatibility
5. Plugin versions for intermediate stops
6. Verify the API shape before writing a snippet
7. Path A: plugins in the `sanity-io` org
8. Path B: plugins outside it
9. What never to recommend
10. Drafting a useful issue
11. Reporting
---
## 1. Verify every plugin, assume nothing
Both directions of assumption produce wrong answers in front of a customer.
**Assuming a Sanity-published plugin is compatible** is wrong because a plugin can lag a Studio major by days or weeks. Sanity plugins are *expected* to have a compatible release, and usually do, but "expected" is not "verified." Asserting it is the same freshness failure this skill exists to prevent.
**Assuming a third-party plugin is stale** is wrong because plenty are actively maintained and current. Telling a customer that a well-maintained plugin is abandoned is both inaccurate and unfair to whoever maintains it, and some of those maintainers are active in the Sanity community.
So keep two questions separate:
- **Is it compatible?** Answered by the peer range and publish data. Same method for every plugin regardless of who owns it.
- **Who can fix it if it isn't?** Answered by ownership. This determines the path, not the verdict.
## 2. Enumerate what is installed
Collect from every source, because the config and the manifest do not always agree:
- The `plugins` array in `sanity.config.*`, including any workspace-specific arrays
- Every dependency matching `sanity-plugin-*` or the `@sanity/*` scope
- Anything imported in the config that registers Studio behavior but is named neither way
Check `devDependencies` too. Plugins land there by accident and still get bundled at build time, which is worth flagging on its own.
Note any package that does not resolve from the public registry. That means a private or internal registry, and nobody outside the customer's own organization can assess or fix it. Report those by name and hand them to their team rather than guessing.
## 3. Determine ownership
**Use the `repository` field on the registry manifest. Do not use the package name, and do not maintain a list of plugin names.**
```sh
npm view <plugin> repository --json
```
A `repository.url` containing `sanity-io/` means the package lives in a Sanity-owned GitHub repository, most often the plugin monorepo at `github.com/sanity-io/plugins`, where the manifest also carries a `directory` pointing at the specific package.
**Why the package name is the wrong test.** `sanity-plugin-media` is an unscoped name, and it lives in `sanity-io/plugins` with Sanity.io as its author. Classifying by the `@sanity/` scope would report a plugin Sanity publishes as unmaintained third-party software. The `repository` field gets it right, costs one field on a query you already make, and never goes stale.
**What being in the monorepo does and does not mean.** The monorepo's own README describes it as the home for Studio plugins maintained by Sanity staff *and the community*, and it holds dozens of packages across both. So presence there means releases are coordinated and someone has commit access. It is not a published support tier. There is currently no per-plugin maintenance-status metadata, so do not state or imply a support level. Describe what you can verify: where it lives, when it last published, and whether its peer range accepts the target.
## 4. Assess compatibility
Run the same checks for every plugin.
```sh
npm view <plugin>@latest version peerDependencies dependencies --json
npm view <plugin> time.modified --json
```
| Check | What it tells you |
| --- | --- |
| **Does *every* peer range resolve, not just `sanity`?** | The most commonly botched check. A plugin declaring `sanity ^3.0.0` can be simultaneously unsatisfiable on `@sanity/ui ^1.2.2`, `styled-components ^5.3.8`, and `react ^18.2.0`. Reading only the `sanity` peer produces a "compatible" verdict for a plugin that will not install. Read the whole `peerDependencies` object and check each entry against what the tree will actually provide. |
| If not, does *any* published version work? | `npm view <plugin> versions --json`, then inspect recent ones. Customers pin and forget; support may already exist. |
| Does the plugin's own `@sanity/*` dependency cross a major relative to what the target `sanity` depends on? | Two majors of a shared package in one tree causes duplicate-context errors and unstyled components. A *patch* release of a plugin can do this, so check the specific version you plan to install, not just `latest`. When it happens, look for an earlier patch that still matches core, and pin to it. |
| Are the plugin's `peerDependencies` all present in the project's `package.json`? | Missing peers often work by accident through hoisting and stop working once the tree is rebuilt. |
| How long since the last publish? | Evidence, not a verdict. Report the date and let the reader judge. |
| How far is the installed version behind the latest? | A large gap often means the plugin's own breaking changes are in scope too, not just the Studio's. |
**A plugin already violating its own peers today belongs in the report's "needs attention before you start" section** (section 3 of `report-template.md`), not only in the upgrade blockers. If a plugin only installs because peer resolution is relaxed, that is true before anyone touches a version, and it means the plugin has to leave at the *first* stop rather than travelling as far as its `sanity` range suggests.
Read the plugin's own release notes for the versions being crossed. A plugin major has its own migration requirements, and those belong in the plan alongside the Studio's.
## 5. Plugin versions for intermediate stops
Section 4 answers whether a plugin has a version that works with the **target**. A multi-stop sequence needs that answer for **every stop along the way**, and this is the step most easily skipped, because a plugin's current version usually peers only recent Studio majors.
The trap is concrete. A plugin whose latest peers `^5 || ^6.0.0-0` tells you nothing about whether any published version peers `^4`. If the plan recommends stopping at the last v4 release, every plugin needs a version whose range accepts v4, or that stop is not reachable at all and the sequence is wrong.
Resolve it by walking versions backwards from the newest:
```sh
npm view <plugin> versions --json
npm view <plugin>@<version> peerDependencies --json
```
**The selection criterion has two halves, and taking only the first is the most common way a stop goes wrong.** You are looking for the newest version that satisfies both:
1. Its `sanity` peer range accepts that stop's major.
2. **Its own dependencies on shared packages match what that stop's core depends on.** `@sanity/ui` and `@sanity/icons` are the ones that matter, because they carry theme and context.
Check the second half explicitly, per candidate version:
```sh
npm view sanity@<stop-version> dependencies --json | grep -E '@sanity/(ui|icons)'
npm view <plugin>@<candidate> dependencies peerDependencies --json | grep -E '@sanity/(ui|icons)'
```
Half one alone points at `latest` almost every time, and `latest` tracks *current* core, not the core at an intermediate stop. Following it puts two majors of a theming package in the tree at exactly the stop you were trying to isolate.
**When the two halves disagree, walk back further.** A plugin's shared-package dependency usually crosses a major at a *patch* release, so there is normally a slightly older patch on the same feature line that keeps everything you need and still matches core. Find it rather than accepting the duplicate.
Note the asymmetry that keeps this tractable: a plugin peering a range that spans several majors can stay on one version across all of those stops. Only the boundaries where a range ends need a different version. So the work is proportional to the number of range boundaries, not stops times plugins.
### Two rules that make a stop real
**A preparatory stop must pin.** A stop described as "clean things up without changing `sanity`" that leaves the existing caret ranges in place is not a stop, it is a fresh resolve against whatever the registry happens to hold that day. In practice it resolves to the *newest* patch of every plugin, which is frequently a worse dependency tree than the carefully chosen set at the next stop. Pin a preparatory stop to the versions the current lockfile already resolves, which is what "don't change anything yet" actually means. This is doubly important when the plan elsewhere identifies unpinned ranges as the cause of deployment drift; keeping them in stop zero contradicts that finding.
**Check a candidate version's peers against the rest of that stop's tree, not just against core.** A plugin can satisfy the stop's `sanity` version and still fail against a sibling. The failure looks like `unmet peer @sanity/mutator@^3.36.4: found 4.22.1`, where the 4.x copy arrived through a different plugin in the same set. Compatibility is a property of the whole set at that stop, not of each plugin against core individually.
**Never accept a duplicate major without first proving no version avoids it.** This is the rule that catches the mistake above after the fact, and it is worth stating separately because the failure has a distinctive shape: the plan takes `latest` for a plugin, notices the duplicate, and then *argues* for it, with language like "accept the temporary duplicate, keep this stop short, and do not ship it to editors for long." That paragraph is a tell. Reasoning appears exactly where the version walk should have been, because no walk happened.
A worked example of what the walk finds. Suppose the stop is the last v5 release, whose core depends on `@sanity/ui ^3.2.0`, and a plugin is needed at its v5 feature line because that is where a data migration ships:
- The plugin's `latest`, 5.2.4, depends on `@sanity/ui ^4.0.7`. Taking it means two majors of `@sanity/ui` at this stop.
- Walking back: `@sanity/ui 4` first appears at 5.1.26. Every version from 5.0.3 to 5.1.25 stays on `@sanity/ui` 3.x.
- 5.1.2 depends on `@sanity/ui ^3.2.0`, which is exactly what the stop's core depends on, and it still carries the v5 migration.
So the duplicate was never necessary, and the correct pin is the older patch. Move the plugin to `latest` at the *next* stop, where core has moved to `@sanity/ui` 4 and the two agree again.
**If a stop genuinely has no version that satisfies both halves**, say so, name the versions you checked and what each depends on, and only then accept the duplicate. And do not schedule anything that depends on a working Studio UI at that stop: a data migration verified through a Studio with two theme contexts is a migration verified in the one state this whole file exists to avoid.
Three outcomes, and the plan has to say which one applies for each stop:
- **A version exists.** Name it in the sequence, at that stop. Not "check at execution time."
- **No published version accepts that major.** The stop is unreachable with that plugin installed. The options are to skip the stop and cross two majors at once, remove the plugin for the intermediate hop and reinstall it at the end, or drop it entirely. Recommend one and say why.
- **You could not determine it.** Mark that stop **provisional** and state exactly what needs checking before anyone commits to the sequence.
Deferring this to "at execution time" reads as thoroughness but is the plan handing back its hardest unanswered question, dressed as a next step. A sequence presented as validated when its middle stop was never resolved is worse than one that admits the gap, because the reader will schedule work against it.
If resolving every plugin at every stop is genuinely too much work for the span, reduce the number of stops rather than leaving them unresolved. Fewer, verified stops beat more, hypothetical ones.
### Never assert what the dependency tree will contain
**You cannot compute a deduplicated tree from declared dependencies, so do not claim one.**
This is a hard limit, not a matter of care. Reading each plugin's declared `@sanity/ui` version tells you nothing about what its own transitive dependencies pull, and nothing about the other shared packages (`@sanity/icons`, `@sanity/client`, `@sanity/util`, `@sanity/types`) that the same set drags along. A set assembled so that every plugin declares the same `@sanity/ui` major routinely resolves to two majors of it anyway, plus doubled copies of three or four other packages. Only a resolver knows.
A read-only planner has no resolver. So the honest form is an expectation plus the command that settles it:
> Expected at this stop: a single `@sanity/ui` 3.5.x. **Unverified** - dependency resolution is the only thing that can confirm it. Check before committing to this stop:
> ```sh
> pnpm install --lockfile-only && pnpm why @sanity/ui @sanity/icons @sanity/client
> ```
Write it that way at every stop, and in the final dependency section. Phrases like "a clean tree is achievable" or "expect exactly one version each" read as findings and are predictions; if one turns out wrong, every other verified claim in the document loses credibility with it.
The same limit applies to peer satisfaction across a whole set. You can confirm that each declared range *could* be satisfied; you cannot confirm the set resolves. Say which of the two you did.
When the reader has already run a resolve and shared the output, that is evidence and you can report it as fact, attributed to the run.
## 6. Verify the API shape before writing a snippet
A plugin's *version* being compatible says nothing about its *API* being what you remember. Plugin majors rename exports, move helpers behind subpaths, and change signatures, and those are exactly the details a reader copies straight out of the plan into their editor.
So: **any snippet you write that names an import path, an export, a signature, or a subpath is either read from the package or marked unverified at the snippet itself.** Two checks, both cheap and both read-only:
```sh
# Does the subpath exist, and what does it point at?
npm view <plugin>@<version> exports --json
# What does the plugin document as the current API?
# Fetch the README for the specific version, not the repo's main branch,
# which may be ahead of what the reader will install.
npm view <plugin>@<version> readme
```
### A name in an export list is not a working export
**The presence of a symbol is not evidence that it works, and confusing the two is worse than not checking at all**, because it produces a verified-sounding "no change needed" on something that is broken.
Sanity's house deprecation pattern across packages is to **keep the name and type it `never`**, so the reader gets a targeted error instead of a bare module-resolution failure. `@sanity/ui` v4 does it for removed props. `@sanity/icons` v5 does it for root-entry icon exports from 5.1.0 on. A grep for the name in `export {...}` or in `dist/index.d.ts` finds it in every one of those cases, and it is removed in every one of those cases.
So the check is three questions, not one:
1. **Is the name exported?** Necessary, not sufficient.
2. **What is its declared type?** `never` means removed. Treat it as absent.
3. **Does it carry `@deprecated`?** The tag text usually names the replacement, which is the fastest route to the correct snippet.
This is the one check in the skill that writes files, so **unpack into a temporary directory and clean it up.** Never run `npm pack` or `tar` in the repository: it drops a tarball and an extracted `package/` into the project, which contradicts the read-and-report rule and leaves the reader with untracked files to explain.
```sh
d=$(mktemp -d)
npm pack --silent --pack-destination "$d" <pkg>@<version> >/dev/null && tar xzf "$d"/*.tgz -C "$d"
grep -B4 "declare const <Name>" "$d"/package/dist/index.d.ts # type plus any @deprecated block
rm -rf "$d"
```
If unpacking is not possible in the environment, the registry manifest still answers most of the question on its own (`npm view <pkg>@<version> exports --json`), and the README covers the rest. Say which of the two you used.
The failure this prevents is specific and expensive: a symbol that resolves, types as `never`, is silently `undefined` at runtime, and gets written into the report's "already satisfied" section as needing no edit. That moves a silent failure into the one place the reader has been told not to look.
If both confirm the shape, write the snippet plainly. If either is unavailable or ambiguous, keep the snippet and mark it inline, per section 8 of `report-template.md`:
```ts
// VERIFY: export name and signature - check the plugin README and its
// package.json `exports` map before running this
import {someHelper} from 'some-plugin/subpath'
```
A parenthetical in the surrounding paragraph does not count. The snippet leaves the document; the paragraph stays behind.
### The internationalization plugins specifically
`sanity-plugin-internationalized-array` and `@sanity/document-internationalization` are among the most widely installed plugins across Sanity's customer base, so a wrong snippet for either one is wrong in many places at once. Two shapes come up in almost every plan that touches them, and both need verifying rather than recalling:
- **The field-level filter helper.** Plans commonly prescribe an `internationalizedArrayLanguageFilter`-style export as a drop-in `filterField`. Confirm the export name, whether it is a factory or a plain function, and what arguments it takes, from the README of the version being installed.
- **The migration subpath.** Plans commonly prescribe a migration imported from a `/migrations` subpath. Confirm that subpath is in the `exports` map for that version, and that the named migration is exported from it.
Neither check needs the project installed. If you skip them, the snippet gets the inline marker.
## 7. Path A: plugins in the `sanity-io` org
**If a compatible version exists**, which is the expected case: recommend the specific version, note any configuration changes from the plugin's own release notes, and move on. Keep it brief; this is routine.
**If no compatible version exists**, treat it as unexpected and handle it differently from a third-party gap.
Do not send the customer to open a GitHub issue about Sanity's own plugin. That reads as being passed back to yourself, and it is the wrong queue.
Instead, report it as a Sanity-side gap with everything needed to escalate:
- Plugin name and the resolved installed version
- Its current latest version and publish date
- Its declared peer `sanity` range
- The target Studio version the range does not accept
- What breaks without it, in one line
Then flag it explicitly for the Sanity contact on the account. In the report, put it under a heading that makes the ownership clear, for example "Blocked on a Sanity-maintained plugin", so nobody mistakes it for the customer's homework.
Give the customer honest interim options and no more: hold the upgrade until the plugin ships support, or proceed without that plugin if the functionality is genuinely replaceable. Say plainly that Sanity is being asked, rather than implying a date.
## 8. Path B: plugins outside the `sanity-io` org
Report what you found as evidence rather than as a judgment: last publish date, peer range, whether the repository is archived, and whether an issue or pull request already references the target version. Let the reader draw the conclusion; a plugin that last published two years ago speaks for itself without being called abandoned.
Then work this ladder in order. It is ordered by what Sanity can stand behind.
**1. Check whether a newer version already works.** Projects pin, then forget they pinned. This resolves the problem more often than expected and costs one query.
**2. Look for existing work on it.** An open issue or pull request referencing the target major means someone is already on it. Link it, and suggest adding their use case as a comment rather than opening a duplicate. A maintainer seeing real users blocked is more likely to ship.
**3. Open a specific, useful issue.** See section 10. This is a good use of the skill's time, because it holds the exact versions and errors a maintainer needs.
**4. Ask whether the plugin is still needed.** Underrated, and often the answer. Studio core absorbs plugin functionality over releases, so something added several majors ago may be solving a problem the Studio now solves natively. Check the current Studio feature set against what the plugin actually does before assuming it has to be replaced. Deleting a dependency is the cheapest possible fix.
**5. Replace or reimplement.** A maintained alternative, or the narrow piece they actually use rebuilt as a custom input. Scope this against what the project uses, not against the plugin's full feature set, which is usually much larger.
**6. Defer, explicitly and temporarily.** Hold the Studio at its current major until the plugin catches up, or vendor the plugin into the project. If the reader chooses this, it needs an exit condition and an owner, or it becomes permanent. Say that.
## 9. What never to recommend
The skill does not recommend forking a third party's plugin, `patch-package`, or installing with relaxed peer resolution as a *solution*.
The reasoning matters more than the rule. These are all decisions to take on permanent maintenance of someone else's code, or to suppress the mechanism that would otherwise warn you about incompatibility. A customer's team may legitimately choose one, but it should be their choice made with open eyes, not a recommendation carrying Sanity's name.
If the customer's team raises one, describe the tradeoff accurately and move on. Specifically on relaxed peer resolution: note that it silences the main signal that would tell them a plugin is incompatible with a future core, which matters most on exactly the upgrade they are doing.
## 10. Drafting a useful issue
When the ladder reaches step 3, draft it. A vague issue gets ignored; a precise one often gets a release. Include:
- **Title:** plugin name, "support for Sanity Studio v\<target major\>"
- The plugin version currently installed and the target Studio version
- The plugin's declared peer `sanity` range, quoted
- The exact install or runtime error, quoted
- Whether the plugin appears to work despite the peer range, if that was tested. Maintainers often only need to widen a range.
- What the project uses the plugin for, in one sentence. This is what tells a maintainer whether the fix is small.
- An offer to test a prerelease
Hand the draft to the reader to post. Do not post it for them.
## 11. Reporting
Give plugins their own section in the report. A table for the routine cases, then a subsection per blocked plugin.
| Plugin | Installed | Latest | Peer `sanity` | Owner | Verdict |
| --- | --- | --- | --- | --- | --- |
| ... | ... | ... | ... | `sanity-io` / third party / private | compatible, bump to X / **blocked** / needs a decision |
For the owner column, state what the `repository` field showed rather than a support level.
For each blocked plugin, a short subsection: what it is used for, why it blocks, which path applies, and the recommended next step from that path. One paragraph each. If a first-party plugin is blocking, make sure that lands somewhere an SA will see it and forward it.
references/report-template.md
# Report template
Write to `SANITY-UPGRADE-PLAN.md` in the repository root unless the user names a different path.
## Rules for filling it in
**R1 to R7 in `SKILL.md` apply to everything below** and are not restated here. The rules in this file are the ones specific to writing the document.
**Omit sections that have no content.** An empty "Custom code" heading is noise. The structure below is a menu, not a form to complete.
**State what is already satisfied.** Requirements the project already meets belong in section 2 as one-liners. It is what tells the reader the upgrade is smaller than they feared, and it stops them re-checking what you checked.
**Section 2 carries the highest cost per error in the document**, because of that last clause: everywhere else a mistake is something the reader trips over, here it is an instruction not to look. The bar for "no change needed" is higher than for a finding, not lower. R7 is the one that bites here.
**Never include an item you could not evaluate.** It goes in the questions section. A finding implies you found something.
**Never assert plugin compatibility you did not verify**, in either direction. Ownership decides the path when something is incompatible, not whether it is.
**Mark an unverified API shape at the snippet, not in the prose around it.** Format is `// VERIFY: <what to check> - <where to check it>`, inline, immediately above or beside the code. A hedge two sentences later does not travel: the snippet gets copied out of the document and the caveat stays behind. See section 8 below.
**Weight findings by severity, and never let the format flatten them.** Severity is likelihood of breaking something multiplied by how hard it is to notice, so silent failures outrank loud ones: a build error gets fixed in ten minutes, a stylesheet that silently stops applying ships to production. Group by what the reader has to do, put silent failures at the top of their group, and demote no-action items out of the main tables.
**Stay inside the upgrade.** Unused dependencies, stale type packages that block nothing, and tidiness in trees the Studio does not deploy from are not upgrade findings. Leave them out or give the whole set one closing line. A plan that drifts into code review spends the reader's attention on things that do not gate the work.
**Describe the state of the tree, not the decisions that produced it.** "The deploy pipeline builds from the caret ranges rather than the lockfile" and "whoever set up CI misconfigured it" carry the same information; only one gets acted on. Reserve "you" for what to do next. This is about attribution, not certainty: keep the evidence and the severity exactly as strong as they are.
**One tree per plan.** Name the lockfile every version came from in the header and take all of them from that tree. A version read from a sibling tree looks authoritative and describes a different install.
**Length is a symptom, not a target.** Never trim a finding that carries file-level evidence to hit a word count. Two checks cut the length that should not be there:
- **A finding without file-level evidence is changelog leakage.** If "applies because" names no file, line, script, or grep result, applicability was never tested. Remove it or move it to the questions.
- **More than roughly fifteen action rows on a three-boundary span** means re-test applicability before accepting the number.
**Link sources.** Every non-obvious claim carries a link to the changelog entry or doc it came from.
---
## Template
````markdown
# Sanity Studio upgrade plan
**Repository:** <path or name>
**Planning for:** <the tree this plan covers, e.g. `studio/` via `studio/pnpm-lock.yaml`. Name the lockfile every version below came from. If the repository has other trees, list them and their `sanity` versions here too.>
**Generated:** <date>
**Current:** sanity <resolved version, from that lockfile>
**Target:** sanity <version> <`latest`, with its publish date. Recommend `latest` unless the reader has stated a constraint; if they have, say so and give the pin a review date. Add one clause naming the fallback so they can overrule you, without building out a second plan for it. **If `latest` published within the last few days, give the date rather than "recently", state what differs in the previous minor, and put the soak-time choice in section 9** rather than leaving it as a header aside. See section 5 of `version-lookup.md`.>
**Span:** <n> major boundaries: <list them>
> Version data retrieved from the npm registry on <date>. Re-verify before acting on this plan if significant time has passed.
## Start here
<**Required, and it must fit on one screen.** Everything below this block is reference material to be read section by section as the work reaches it. This block is the plan.
It contains four things and nothing else:
1. **The sequence**, one line per stop: the version, and the three-to-six-word reason for the stop. No detail; section 10 has it.
2. **Anything that invalidates the plan if skipped**, stated first if it exists. A deploy pipeline that builds a different tree, an unreachable stop, a blocking plugin. One line each.
3. **The changes that fail silently**, by name. These are the reason this block exists: they are the items a reader most easily misses in a table and most expensively discovers in production.
4. **What needs a decision from a human before the work starts**, as a pointer to section 9 with the count. Not the questions themselves.
Introduce nothing here that is not covered below, and repeat nothing here that does not need to be seen first. If this block cannot be written in a screen, the plan has not decided what matters.>
## 1. Summary
<Two or three sentences of sizing, then the two or three things that make this more than a dependency bump. This is the narrative version for someone deciding whether to schedule the work, where the Start here block is the work list for whoever does it, so do not restate that block.
If the honest answer is "a dependency bump plus three edits," say that. If it is "a rewrite of the configuration layer," say that. Frame the hard parts as consequences rather than faults: "fix this first or every stop below is theoretical" says what is at stake without telling anyone they made a mistake.>
## 2. Already satisfied
<One line each, for requirements the project already meets. Include the evidence.>
- Node.js: requirement is >= X, project is on Y
- React: requirement is >= X, project resolves to Y
- No `auth` block in config, so the v6 auth provider change does not apply
- No `data-slate` references, so the 6.3.0 Portable Text DOM change does not apply
## 3. Needs attention before you start
<Open the section with one sentence saying why it is here, in the report itself and not only in this template: these conditions exist in the current tree before any version changes, they may already be causing behavior the team has noticed, and leaving them in place adds variables to every step below. Without that sentence the reader meets a list of faults with no stated purpose, which is the wrong first impression and the wrong reading of what the list is for.>
<Include only what affects the upgrade. Qualifying: duplicate majors of a shared package, a plugin already violating its own peers, a deploy pipeline building from a different manifest or lockfile than the tree being planned, undeclared imports resolving only through hoisting, relaxed peer resolution masking conflicts. Not qualifying: unused dependencies, stale type packages that block nothing, tidiness in a tree the Studio does not deploy from. Those can have one closing line between them, or none.>
<**Order by consequence and say which is load-bearing.** If one of these makes the rest of the plan theoretical when skipped, it goes first and says so plainly. The others follow. A flat list of seven equally weighted items reads as an audit; the same seven, with the one that gates everything named as such, reads as a plan.>
<For each: what the current state is, the evidence, and what it affects downstream. Describe the tree, not the decisions behind it. Give specific versions and what pulled each one in.>
## 4. Requirements to meet
<Hard floors that are not yet met. Node, React, TypeScript settings. Each with where it needs changing: engines, .nvmrc, CI, build host. A requirement the project already satisfies goes in section 2, not here.>
## 5. Breaking changes that apply
<**Group by what the reader has to do, not by which boundary the change came from.** Boundary is a column, not a heading. Grouping by boundary mirrors the changelog's structure rather than the work's, and it puts a build-breaking import next to a cosmetic default at identical weight, which is exactly how the important row gets missed. The boundary still matters for sequencing, so keep it visible per row and let section 10 order the work.>
<For each item: what changed, why it applies here (the evidence you found), what to do, and a source link. Include the failure mode when it is non-obvious.>
### Breaks the build or the Studio
<Loudest and cheapest to find, but still first because nothing else can be tested until these are done. Failures that are silent go at the top of this group and say so, since a silent failure outranks a loud one of the same size.>
| Change | Boundary | Applies because | Action |
| --- | --- | --- | --- |
| ... | ... | ... | ... |
### Changes behavior, needs a decision
<Defaults that flipped, strategies that changed, anything that writes different data or shows editors something different. Each one needs an owner or a decision, not just an edit. Cross-reference section 9 where the decision belongs to a human.>
| Change | Boundary | Applies because | Decision |
| --- | --- | --- | --- |
| ... | ... | ... | ... |
### Worth knowing, no action
<Everything that applies but requires no change: informational defaults, deprecations still functional at the target, items already covered by another section. **Keep these out of the tables above.** One line each, as a plain list, so they are on the record without competing for attention with work that has to happen.>
- <version, boundary> ... (informational)
## 6. Dependency changes
<A single code block the reader can diff against their package.json. Comment each changed line with the old value and the reason. Include additions for missing peer dependencies.>
```jsonc
"dependencies": {
"sanity": "<version>", // was <version>
...
}
```
<Then the dedupe verification command and what to expect from it.>
## 7. Plugins
<A row per plugin. The owner column states what the registry `repository` field showed, not a support level. Keep compatible plugins to one line each; they are routine.>
| Plugin | Installed | Latest | Peer `sanity` | Owner | Verdict |
| --- | --- | --- | --- | --- | --- |
| ... | ... | ... | ... | `sanity-io` / third party / private | compatible, bump to X / **blocked** / needs a decision |
<Then one short subsection per blocked plugin: what it is used for, why it blocks, and the recommended next step. Use a heading that makes ownership unambiguous, so a Sanity-maintained gap is not mistaken for the reader's homework.>
### Blocked on a Sanity-maintained plugin: <name>
<Installed version, latest version and publish date, its declared peer range, the target it does not accept, and what breaks without it. State that this is being raised with Sanity rather than implying a date. Give only the honest interim options.>
### Blocked on a third-party plugin: <name>
<The evidence: last publish date, peer range, whether the repo is archived, whether an issue or PR already tracks the target. Then the recommended step from the ladder, including whether the plugin is still needed at all.>
## 8. Code changes
<Only if there are any. File path, then the specific edit. Show before and after for anything mechanical. If there are many instances of one pattern, give the count and one example rather than listing all of them.>
<**Every snippet whose API shape you did not read from the package carries an inline marker.** Verified snippets carry nothing; the absence of a marker is the claim that you checked. Format:>
```ts
// VERIFY: export name and signature - check the plugin README and its
// package.json `exports` map before running this
import {someHelper} from 'some-plugin/subpath'
```
<Use `VERIFY` rather than `UNVERIFIED`. The information is identical and the imperative is more useful: it tells the reader what to do about it, and it does not read as a disclaimer about the document as a whole.>
<Do not replace the marker with a sentence in the surrounding paragraph. The snippet is what gets copied; the paragraph is not.>
## 9. Questions only you can answer
<The judgment half. Each question should say why it matters and what changes based on the answer. This section is the honest boundary of what static analysis can determine, and it is usually the most valuable part of the plan for whoever has to act on it.>
Typical entries:
- Which Node version runs in CI and on the build host? Detected <X> locally, which may not be the same.
- Do editors find documents by typing the name of a related record? The v6 search default change stops reference-traversed preview fields contributing to matching.
- <Any fact that was out of reach: a tsconfig extending outside the repo, a hoisted dependency, a CI config elsewhere. Name it specifically.>
- Is content consumed by anything that cannot be updated quickly, such as a mobile app or a partner integration? Two default changes write new characters and new marks into stored content.
- Are there plugins from a private registry not visible in the public dependency data?
## 10. Suggested sequence
<Numbered, with intermediate stops at the last release of each major and the reasoning for each stop. Include where to verify before proceeding. For multi-boundary spans, make the stops explicit; for a single boundary, this can be four lines.>
<**A stop that accepts a duplicate major has to show the version walk that justifies it.** Name the candidate versions checked and what each depends on. Without that, an accepted duplicate is indistinguishable from a plugin set that was taken at `latest` and rationalized, which is the most common defect in a multi-stop sequence. See section 5 of `plugins.md`.>
<**Do not schedule work that depends on a working Studio UI at a stop with a duplicate theming package.** A content migration, or any step verified by looking at the Studio, belongs at a stop with a single `@sanity/ui` major.>
<**Every stop needs its plugin versions named, or an explicit note that the stop is provisional.** A stop that says "bump plugins to versions that accept this major, check at execution time" is not a validated step: it hands back the hardest unanswered question in the plan. If you could not resolve a stop, label it provisional and say what needs checking. See section 5 of `plugins.md`.>
<**A skipped stop needs its cost stated, not just its justification.** Dropping a boundary whose only applicable change is a Node floor, or one that cannot be reached without a forced duplicate, is frequently the right call, and the evidence for it belongs here. But the hop that results crosses two majors at once, so add the consequence in the same breath: a failure in that hop could come from either boundary, and isolating it means bisecting rather than consulting this plan. Showing the evidence and omitting the cost makes the trade look free.>
## 11. Test checklist
<Derived from what this repository actually has, not a generic list. If there are no custom document actions, do not include a line about testing them. Split into build-and-types and functional.>
**Build and types**
- [ ] ...
**Functional**
- [ ] ...
## 12. Sources
<Every changelog entry, migration guide, and registry query the plan relied on. The reader should be able to check your work.>
````
---
## What a good report is not
**Not the changelog.** If the report contains items whose applicability was never tested, it has failed at its only real job. The reader can already read the changelog.
**Not padded with generic advice.** "Test thoroughly before deploying" adds nothing. "Publish a document that previously failed validation, because 6.7.0 changed URI validation with custom schemes" is worth reading.
**Not an audit of the repository.** The plan reports the current state only where it bears on the upgrade. Every finding that does not gate the work spends attention that the findings which do gate it needed, and a document that opens with a long list of everything wrong reads as a judgment on the team rather than a route through the work.
**Not flat.** A plan where every finding has the same visual weight has pushed the triage back onto the reader, which is the job it was supposed to do for them. If the most dangerous item in the document is not findable in the first thirty seconds, the structure has failed regardless of how accurate the content is.
**Not falsely reassuring.** If the project imports from `sanity/_internal`, or has forty `@sanity/ui` import sites, or crosses three majors, the report should say the upgrade is substantial. A plan that makes hard work look easy fails the reader more expensively than one that overestimates.
**Not silent about its own gaps.** If a registry lookup failed, if a tsconfig could not be resolved, if the CI config was not found, the report says so. The reader can fill a named gap; they cannot fill one they do not know about.
references/version-lookup.md
# Version lookup and changelog retrieval
Version facts must come from a live source at the time the plan is generated. This file is how.
## Contents
1. Why this is non-negotiable
2. Current versions from the registry
3. Confirm every version you recommend actually exists
4. What a target release depends on
5. Choosing a target version
6. Claims about what a package requires
7. Changelog retrieval
8. When lookups are unavailable
---
## 1. Why this is non-negotiable
R1 in `SKILL.md` states the rule. This is why it is absolute rather than a preference: a model's training data holds a snapshot of npm from some point in the past, and Sanity Studio ships roughly weekly. A version recalled rather than retrieved will eventually be wrong, and wrong in the most damaging way, which is stated confidently in a document someone refactors against.
The four failures this prevents:
- Recommending a "latest" that is several majors behind, or ahead of what exists
- Missing that a shared package published a new major since training
- Missing that a plugin's patch release quietly bumped a major dependency, so the safe pin is an earlier patch
- Telling someone a package's peer range accepts their target when it does not
## 2. Current versions from the registry
Prefer whichever of these works in the environment.
```sh
# All dist-tags at once: latest, stable, next, and any prerelease channels
npm view sanity dist-tags --json
# A single package's current latest, with its requirements
npm view sanity@latest version engines peerDependencies --json
npm view @sanity/ui@latest version engines peerDependencies --json
```
Direct registry endpoints, if `npm` is unavailable or blocked:
| Endpoint | Returns |
| --- | --- |
| `https://registry.npmjs.org/-/package/<pkg>/dist-tags` | All dist-tags. Small response. |
| `https://registry.npmjs.org/<pkg>/latest` | Full manifest for `latest`: version, `engines`, `dependencies`, `peerDependencies` |
| `https://registry.npmjs.org/<pkg>/<version>` | Same, for a specific version |
Scoped package names need URL encoding in some clients: `@sanity/ui` becomes `@sanity%2Fui`.
Query at minimum: `sanity`, `@sanity/vision`, and every `@sanity/*` and `sanity-plugin-*` the project depends on directly.
A note on dist-tag listings: some Sanity packages carry a large number of prerelease and feature-branch tags. The `sanity` package carries well over a hundred, most of them long-dead feature branches. When a listing is long, read the tags you need by name rather than trusting a summary of the whole list. If a tag listing and a `/latest` manifest disagree, the `/latest` manifest wins.
**`latest` is the target. `stable` is a different tag and it lags.** Observed 2026-09-04: `latest` was 6.12.0 while `stable` was 6.7.0, five minors behind. Do not treat `stable` as a more trustworthy `latest`, and do not recommend it because the name sounds safer. If a plan is going to deviate from `latest`, that has to come from a constraint the reader stated, not from a tag name.
Two other tags on this package are genuinely useful, because they name the terminal release of a maintenance line, which is exactly what an intermediate stop needs: `maintenance-v4` and `maintenance-v5`. Read them rather than assuming the highest minor is the last release of a line, because both point at a patch above it.
**Dist-tags do not tell you what exists.** A tag is a pointer someone has to move; a version can be published without `latest` following it, so the highest published version is often ahead of what `latest` points at. Reading `latest` as "the set of releases that exist" produces a specific wrong conclusion: declaring a real version nonexistent because a tag lags behind it.
So use dist-tags to answer "what should I target" and the version list or a direct manifest fetch to answer "does this version exist". They are different questions with different sources:
```sh
npm view <pkg> dist-tags --json # what to target
npm view <pkg> versions --json # what exists
npm view <pkg>@<version> version # does this specific one exist
```
## 3. Confirm every version you recommend actually exists
**Before any version number goes into the report, confirm that exact version was published.**
This sounds redundant next to section 1, but it catches a distinct and worse failure. Section 1 prevents *stale* versions. This prevents *invented* ones: a version number that looks right because it pattern-matches something else in the plan, and was never published at all.
The damage is asymmetric. A stale version installs and works, just not on the newest release. A nonexistent version fails at install, and the reader's first experience of the plan is an error.
```sh
# Does this exact version exist?
npm view <pkg>@<version> version
# Or list what exists and check
npm view <pkg> versions --json
```
The pattern that produces this mistake: assuming one package's version tracks another's because their numbers look alike, then writing the number rather than querying it. Any package you are about to label "lockstep", "matches core", or "same version as" needs an existence check, because that reasoning is exactly what generates a plausible number nobody published.
Run the check on every pinned version in the dependency block, not just the ones that felt uncertain.
## 4. What a target release depends on
This step is what prevents the most common upgrade failure. Before recommending a version for any `@sanity/*` package the project depends on directly, find out what the *target* `sanity` release depends on:
```sh
npm view sanity@<target-version> dependencies --json
```
Then match that range rather than the package's own `latest`. See `package-coupling.md` for why.
Also record the target's requirements:
```sh
npm view sanity@<target-version> engines peerDependencies --json
```
`engines.node` and the `react` / `react-dom` / `styled-components` peer ranges are the hard requirements the plan opens with.
## 5. Choosing a target version
**Target `latest`. Recommend it, and say why.**
Two reasons, and the second is the one people miss.
The obvious one: the point of the upgrade is to stop being behind. Landing short means doing the whole job and still needing another cycle sooner, and the second cycle costs nearly as much as the first because the same testing has to happen again.
The one that actually decides it: **plugin ecosystems track `latest`, so an older core with current plugins is frequently less coherent than `latest` with current plugins.** Current plugin versions depend on the shared packages that recent core releases depend on. Aim at an older release and you can land on a core whose `@sanity/ui` major differs from what every plugin needs, which is a duplicate-major tree - the exact failure `package-coupling.md` exists to prevent. Choosing a "safer" older release can therefore produce a *worse* dependency graph than the newest one.
So `latest` is the recommendation. Not "latest unless it feels new."
### What still gets reported
Retrieve and state these. They are disclosure so the reader can apply constraints you cannot see, not hedges, and they do not change the recommendation.
```sh
npm view sanity dist-tags --json
npm view sanity time --json # publish dates per version
```
- **The target version and its publish date.** If it is days old, say so in one clause.
- **The one-line fallback**, and only one: the previous minor, or `stable`. Give the version and what differs, in a sentence. Do not build out a parallel plan for it.
Frame it as recommendation plus disclosure: "Target 6.12.0 (`latest`, published two days ago). If your change process needs more soak time, 6.11.0 has the same dependency shape." That is a decision the reader can act on, with the information to overrule it.
### When `latest` published in the last few days
**A target that is hours or days old still gets recommended, but the soak-time question stops being an aside and becomes a question in its own right.**
`latest` is `latest` the day it ships, and the argument for it does not weaken: plugin releases track current core, so an older core with current plugins is usually the worse tree. What *is* different is that nobody has run this release in production yet, so there is no field evidence either way, and a reader planning a multi-week enterprise migration may reasonably want a week of other people's bug reports first.
When the target published within roughly 72 hours:
- Say so plainly in the header, with the date, not just "recently".
- Name the previous minor, and state what actually differs between them, read from both manifests rather than assumed. Usually it is one or two shared-package ranges, and that is a one-line answer the reader can weigh.
- **Put the choice in the report's questions section**, not only in the header. It is a change-process decision that depends on the team's release appetite, which is exactly the kind of thing a planner cannot determine from code.
What not to do: recommend the older release on its own initiative because the newer one feels untested. That is the planner substituting its own risk tolerance for the reader's, and `stable` is not a safer synonym for `latest` either. Recommend, disclose, and let them decide.
### Dependency majors inside a minor release
Still check this, but it no longer argues for landing earlier:
```sh
npm view sanity@<target> dependencies --json
npm view sanity@<previous-minor> dependencies --json
```
If a shared package crossed a major somewhere in the recent minors, that migration is **inside the target**. Name it in the plan and scope it as part of the work, rather than proposing an older release to avoid it. Avoiding it usually means inheriting a duplicate-major tree instead, which is worse and harder to explain.
### When something blocks `latest`
If a plugin's peer range accepts no version of the target, that is a plugin problem, not a reason to aim at an older core. Work `plugins.md` - is a compatible version available, is the plugin still needed, can it be replaced. Retargeting the whole upgrade around one unmaintained dependency is almost never the right trade.
### The only reason to deviate
A constraint the reader has actually stated: a change freeze, a pinned platform runtime, a policy requiring N weeks of soak. Then pin, name `latest` as the eventual destination, and give the pin a review date so it does not become permanent by default.
Absent a stated constraint, recommend `latest`.
## 6. Claims about what a package requires
**Every statement of the form "package X requires Y" comes from X's own manifest, read during this session.** Never from memory, never from a docs page, never inferred from a sibling package.
```sh
npm view <pkg>@<version> engines peerDependencies dependencies repository --json
```
The fields that carry requirements: `engines` for runtime floors, `peerDependencies` for what the host must supply, and the presence or absence of `typesVersions` and `exports` for whether legacy TypeScript resolution still works.
Four ways this goes wrong, all of which produce confident, wrong, sequencing-relevant claims.
**Reading a range instead of evaluating it.** Having the manifest in front of you is not the same as knowing what it accepts. Compound ranges are the trap: `>=20.19 <22 || >=22.12` looks like it excludes Node 21 and accepts all of Node 22, and it does the opposite of both, accepting every 21.x and rejecting 22.0 through 22.11. Space-separated comparators are an AND, not an OR, so `>=20.19 >=22.12.0` is just `>=22.12.0` with a dead clause in front of it.
Any range with a `||`, a `<`, or two comparators in one clause gets evaluated before it goes in the report:
```sh
node -e "console.log(require('semver').satisfies('<version>','<range>'))"
```
Test the version the project is actually on, and the boundary values either side of every comparator. This costs one command and prevents the worst version of a requirements error: telling a reader to change a runtime that was already inside the supported range.
**Inventing a constraint.** Asserting that something requires the newest major when it accepts several tells the reader to defer work they could do immediately. The plan is then not merely inaccurate, it is ordered wrong as a consequence.
**Attributing one package's field to another.** Quoting a version range and naming the wrong package makes a correct finding uncheckable, and it is easy to do when you have several manifests open. State the requirement and the package you read it from in the same breath: "`sanity@4.4.0` declares `engines.node: >=20.19 >=22.12.0`." If two packages express the same constraint differently, quote each from its own manifest rather than normalizing them into one string.
**Assuming a requirement carries backwards across majors.** A constraint introduced in v4 of a package is not evidence that v3 had it. `typesVersions` is the trap here: a package can drop it in one major while the previous major still ships it, so the `moduleResolution` requirement applies at one stop and not the earlier one. Check the specific version the plan installs at each stop, not the newest.
**Where a docs page and a manifest disagree**, say so and treat the manifest as what will install and the docs as what is supported. Do not silently pick one.
### Replacement packages specifically
When pointing at a successor for something deprecated, do all of the above plus:
- **Check the version number.** Below 1.0.0 is a different proposition: the API may still move and its peer ranges may narrow. Recommending one is often still right, particularly when it is the documented successor, but say the version and note that it is pre-1.0.
- **Record where it lives** via `repository`, so the reader knows whether the same people maintain it as the thing it replaces.
## 7. Changelog retrieval
The references in this skill cover known items up to a fixed date. For anything after it, and to catch items released in minor versions without a breaking-change label, read the changelog.
**Via the Sanity MCP server, if connected.** `search_docs` and `read_docs` are the most reliable route and return clean markdown.
```
search_docs("Studio v6 breaking changes")
read_docs("https://www.sanity.io/docs/studio/upgrade")
```
**Changelog URLs.** Entries live at `https://www.sanity.io/docs/changelog/<id>`. Appending `.md` returns the entry as markdown, which is easier to parse:
```
https://www.sanity.io/docs/changelog/<id>.md
```
Some entries use a slug of the form `studio-<base64 of a version string>`, for example `studio-NS4zMS4w`. This lets you construct candidate URLs directly, but treat it as a heuristic: the encoded version does not always match the version the entry states. Verify by reading the entry rather than trusting the slug.
The category-filtered index is a useful starting point:
```
https://www.sanity.io/docs/changelog?category=studio
```
**Raw CHANGELOG.** The repository's generated changelog is the most complete source, and it contains items that never made it into a curated entry:
```
https://raw.githubusercontent.com/sanity-io/sanity/main/CHANGELOG.md
```
It is large. Fetch it with a specific question rather than reading it whole, and search for `BREAKING`, `breaking change`, `deprecat`, `removed`, and `no longer`.
**It starts at 3.91.0.** Verified 2026-09-04: the file on `main` covers 3.91.0 onward, and the tagged trees for older releases do not contain it either. So there is **no consolidated changelog for 3.0.0 through 3.90.x**, and a fetch aimed at that range comes back nearly empty.
Do not read that emptiness as an absence of breaking changes. It is an absence of *source*, and the two must not be reported the same way. Say which range you could not cover, and see the v3 line note in `boundaries.md` for the wording. The per-release changelog entries on the docs site remain the fallback for individual older releases, but they are not a substitute for a continuous history of a hundred minors.
**One useful lower-bound anchor that does not depend on the changelog:** the registry manifest for any release is always available, so requirement fields such as `engines.node` and `peerDependencies` can be read for *any* version, including ones the changelog never covered. When the narrative history is missing, the requirement history is still verifiable. Use it, and say that is what you did.
**Migration guides.** Confirmed to exist:
| Boundary | Document |
| --- | --- |
| v3 to v4 | `https://www.sanity.io/docs/help/v3-to-v4` |
| v4 to v5 | Changelog entry `fd3ab62e-9264-4e7b-825a-fd4f99abd481` |
| v5 to v6 | Changelog entry `studio-NS4zMS4w`, plus the blog post `https://www.sanity.io/blog/sanity-studio-v6` |
| General | `https://www.sanity.io/docs/studio/upgrade` |
Studio v2 is out of scope for this skill and is a hard stop; see the scope section of `SKILL.md`. When redirecting a v2 project, point the reader at the current v2 to v3 migration guide on the docs site rather than reciting a URL or its contents from memory.
**Third-party plugins.** For anything not published by Sanity, read the package's own repository releases. Peer ranges in the registry manifest tell you compatibility; the release notes tell you what changed.
### Confirm you read the range you claim to have read
The raw CHANGELOG is a large file, and whatever fetches it may truncate it without saying so. That produces a specific and damaging failure: reporting "scanned 6.9.2 through 6.12.0, no breaking changes found" when the content actually stopped at 6.9.1, so the newest releases were never examined at all and the report implies otherwise.
Before claiming a range, check the highest version present in the content you received. If it stops short of your target, say what you actually covered and mark the remainder unverified. An honest "I reviewed through X, and X to Y still needs checking" is useful; a claimed scan that did not happen is how an unsupported finding ends up in a report looking like the product of research.
When the raw file truncates, fall back to the per-release changelog entries, which are small enough to retrieve individually.
### Citations
**If you cite an issue or pull request number, you must have retrieved it.** Do not attach one to a change you learned about elsewhere.
Issue numbers are trivially easy to produce and effectively impossible for a reader to sanity-check without clicking through, so a wrong one makes an otherwise accurate finding unverifiable, and it undermines every other citation in the document. If you know a change shipped but not its issue, cite the release it shipped in and describe the change. That is both more useful and checkable.
## 8. When lookups are unavailable
If the environment has no network access, or registry queries fail:
1. Say so at the top of the report, prominently.
2. Report the resolved current versions from the lockfile, which do not need network access, and the applicability findings from the repository, which also do not.
3. Do not name any target version. Instead, state the commands the reader should run.
4. Frame the output as a partial plan pending version verification.
A partial plan that is honest about its gap is useful. A complete-looking plan built on remembered version numbers is a liability.
SKILL.md
---
name: sanity-studio-upgrade
description: Produces a tailored Sanity Studio upgrade plan by inspecting the repository's installed versions, config, and source, then reporting only the breaking changes that actually apply. Covers Studio v3 and later; v2 projects are identified and redirected, not planned. Use this skill whenever someone wants to upgrade, migrate, or modernize a Sanity Studio across one or more major versions from v3 onward, asks what will break if they bump the `sanity` package, asks why their Studio broke after an upgrade, or asks how far behind their Studio is. Triggers on "upgrade sanity studio", "migrate our studio to v6", "bump sanity", "what breaks if we upgrade", "our studio is on an old version", "sanity upgrade plan", "is our studio out of date", "we are several majors behind". DO NOT load for upgrading non-Sanity dependencies, for Content Lake `apiVersion` questions, for content or schema migrations that change documents, or for setting up a new Sanity project.
compatibility: Requires network access to the npm registry and sanity.io docs for version and changelog lookups
---
# Sanity Studio upgrade planner
Generate an upgrade plan for the Sanity Studio in the current repository.
The plan's value is **subtraction**. A list of every breaking change between two versions already exists; it is called the changelog, and it is not useful to someone mid-upgrade. This skill produces the much shorter list of changes that apply to *this* repository, plus the questions only a human can answer.
## Three rules that determine whether the output is trustworthy
**1. Never state a version number from memory. Look it up, and confirm it exists.**
Package versions change weekly. Every version in the report must come from either the repository's lockfile (for current state) or a live registry query (for targets). If a lookup is not possible, say so in the report rather than guessing. A plan that names a stale "latest" is worse than no plan, because the reader will act on it.
There is a second, worse version of this failure: a version number that was never published at all, arrived at by assuming one package tracks another's numbering. That one fails at install. Any version you are about to describe as "lockstep" or "matches core" needs an existence check first.
The same applies to API names. An export, an import subpath, or a function signature recalled from memory is a guess, and it is a guess the reader will paste into their editor. Read it from the package's README or `exports` map, or mark it with an inline `// VERIFY:` comment **at the snippet itself**. See section 6 of `plugins.md` and section 8 of `report-template.md`.
**2. Read and report. Never modify.**
Inspect files, run read-only shell commands, query registries. Do not edit `package.json`, config files, or source. The reader decides what to change; a planner that edits code will be turned off. If the user explicitly asks you to perform the upgrade afterward, that is a separate task they have consented to.
**Where the line actually falls**, because a vague version of this rule makes an agent hesitate over commands that are fine:
- **Never**: writing to any file in the repository, installing into it, or mutating a lockfile. `pnpm install`, `npm install`, `npm ci` and `npx` without `--no-install` are all out.
- **Fine**: reading files, and network reads such as `npm view`, a changelog fetch, or `npm pack` **into a temporary directory outside the repository** that you then delete.
- **Recommended to the reader, not run by you**: `pnpm install --lockfile-only` is how a dependency prediction gets settled. It belongs in the report as a command for them to run, with what to expect from it. Do not run it yourself, because it rewrites their lockfile.
The one place this skill writes anything is unpacking a tarball to inspect type declarations, and section 6 of `plugins.md` shows how to keep that outside the project.
**3. Separate what you verified from what you inferred.**
Every finding lands in one of three buckets: confirmed applicable (you found the condition in the repo), needs a human answer (you cannot determine it from code), or not applicable (omit it entirely). Never pad the report with items you could not check. An honest short report beats a comprehensive-looking one.
One consequence is easy to miss and worth stating outright: **a read-only planner has no dependency resolver, so it cannot know what a tree will contain after a change.** Predictions about deduplication belong in the report as expectations with the verification command attached, never as findings. See `package-coupling.md`.
## The verification rules, in one place
These are referred to by number throughout the reference files, so they are stated once here rather than restated in each. Everything above is R1 to R4; the rest are the specific ways those go wrong.
| | Rule | Where it bites |
| --- | --- | --- |
| **R1** | Never state a version from memory. Take current state from the lockfile, targets from a live registry query. | `version-lookup.md` §1 |
| **R2** | Read and report. Nothing in the repository changes, nothing is installed, no lockfile is mutated. | Above, and `plugins.md` §6 |
| **R3** | Separate verified from inferred. Never assert what a dependency tree will contain after a change; a planner has no resolver. | `package-coupling.md` §6, `plugins.md` §5 |
| **R4** | Confirm every recommended version was actually published before it goes in the report. | `version-lookup.md` §3 |
| **R5** | Every "X requires Y" names the manifest it was read from, at the version being installed at that stop. Requirements do not carry backwards across majors. | `version-lookup.md` §6 |
| **R6** | Evaluate a version range, never read it. Anything with a `\|\|`, a `<`, or two comparators in one clause goes through `semver.satisfies` first. | `version-lookup.md` §6 |
| **R7** | A name in an export list is not a working export. Check its declared type and any `@deprecated` tag; `never` means removed. | `plugins.md` §6 |
R6 and R7 exist because both have already produced confident, wrong, specific advice: a Node version reported as unsupported when it was fine, and a required code edit filed under "already satisfied". They are cheap to run and they fail silently when skipped.
## Scope: v3 and later
This skill plans upgrades from Studio v3 onward. **Studio v2 is out of scope and is a hard stop.**
The reason is not that v2 is hard. It is that v2 to v3 is a rewrite of the Studio's configuration and plugin layer rather than a dependency upgrade: the config format changed, the "parts" system that v2 plugins and overrides were built on no longer exists, packages were consolidated, and much of the v2-era plugin ecosystem was never ported. A generated plan for that boundary would be a list of confident-sounding specifics that this skill cannot verify, handed to someone about to spend weeks on the work. That is the worst possible output.
**If you detect v2, stop and report** rather than planning. Step 1 covers detection and what to say. Do not write a plan file for a v2 project.
## Procedure
### Step 1: Establish ground truth, and check the version floor
Read `references/detect.md` and follow it. It lists the files to read, the commands to run, and the facts to record.
The single most common mistake is reading versions from `package.json`. Caret ranges are not installed versions. Get the resolved version from the lockfile.
The second most common mistake is reading them from the wrong lockfile. A repository can contain several installs that disagree, so decide which tree the plan is for, take every version fact from that tree, and name it in the report header.
If there is no Sanity Studio in this repository, say so and stop.
**Then check the floor before doing anything else.** If the resolved `sanity` major is below 3, or there is no `sanity` package and the project depends on v2-era packages such as `@sanity/base` or `@sanity/desk-tool`, or there is a `sanity.json` and no `sanity.config.*`, this is a v2 project. Stop there and tell the reader:
1. What you detected, and the evidence for it
2. That this skill covers v3 and later, and why v2 to v3 is a different kind of project: a rewrite of the configuration and plugin layer, not a version bump
3. That the right starting point is the official v2 to v3 migration guide, which they should find on the Sanity docs site rather than take from you second-hand
4. That every v2-era plugin needs its own compatibility check, since many were never ported
5. That once they are on v3, running this skill again will plan the rest of the span, which is a much smaller job
Be useful about it. A hard stop that explains itself and points somewhere is a good answer; a refusal is not. But do not soften it into a partial plan, and do not estimate the v2 to v3 work.
If both `sanity.json` and `sanity.config.*` are present, treat the resolved `sanity` version as authoritative. A leftover `sanity.json` in an otherwise v3+ project is worth mentioning as cleanup, not a reason to stop.
### Step 2: Determine the span
You need two versions: **from** (resolved, installed) and **to** (the target).
Read `references/version-lookup.md` for how to query current versions and changelogs authoritatively.
**The target is `latest`.** Recommend it and give the reasoning. Landing short means doing the whole job and still being behind, and more importantly, current plugin versions track current core, so an older core with current plugins often produces a worse dependency tree than the newest release does. Report the target's publish date and a one-line fallback so the reader can apply constraints you cannot see, but that is disclosure, not a hedge, and it does not change the recommendation. Deviate only for a constraint the reader has actually stated, and then give the pin a review date.
State the span explicitly in the report. A v3 project and a v5.31 project are completely different jobs and the reader needs to know which one they have.
### Step 3: Load only the boundaries you cross
Read `references/boundaries.md` first. It is a short index: the coverage bounds, the two-edge fetch rule, and a table saying which boundary files the span needs.
**Then read only those files.** Boundary content is split one file per major, so a span that crosses one boundary loads one file rather than all of them. A project on 5.31 crossing into v6 reads the index, `boundary.v6.md` and `deprecations.md`, and nothing else: the v4 and v5 files would cost context and invite findings that do not apply.
Each `boundary.v*.md` holds the crossing *into* that major plus the undeclared changes *inside* that major's line, because a span that crosses a boundary lands somewhere in the line above it and needs both.
Then read `references/package-coupling.md`. It applies to every span.
### Step 4: Fetch what the references do not cover
`references/boundaries.md` declares **two** bounds on its within-line coverage, and both need respecting.
Fetch the changelog for any part of the span that falls **above the upper bound** or **below the lower bound**. A project starting below the lower bound needs two fetches: from its current version up to that bound, and from the upper bound to the target. The curated middle is used as-is.
The lower bound matters as much as the upper one, and it fails more quietly. Without it you will trust the file for a range it never examined and report that nothing there applies, which reads as a verified finding and is actually an unexamined gap. **Never state that a range is clean unless it sits inside the declared bounds or you fetched it yourself and got real content back.**
That last clause is not padding. Below the lower bound the upstream changelog does not exist at all, so the fetch returns almost nothing, and **an empty fetch is not a clean range.** `boundaries.md` says which range this affects and what to report instead. Reporting silence as "nothing applies" there is the single easiest way for this skill to produce a confident lie.
Pay particular attention to breaking changes that shipped in **minor** releases without being labelled as breaking. Those are the ones that surprise people, because nobody reads minor release notes across forty releases. The references list known examples; assume more exist.
### Step 5: Test applicability
For each candidate change, find the condition in the repository. If you cannot determine it from code, it goes in the human questions section, not in the findings.
Examples of the difference:
- "Custom auth providers now replace built-ins instead of appending" → grep the config for an `auth` block. Present or absent. **Determinable.**
- "Default search strategy changed, results will shift" → whether that matters depends on how editors search. **Not determinable. Ask.**
Prefer conditions you can actually evaluate. `grep -r "data-slate"` is a real test. "Do you have complex custom components?" is not.
### Step 6: Resolve the dependency graph
Apply `references/package-coupling.md`. This is where most upgrades actually break, and it is the part a changelog cannot tell you, because it depends on which packages this project happens to depend on directly.
The rule that matters most: for any `@sanity/*` package the project depends on **directly**, check what version the *target* `sanity` release depends on and match that, rather than taking the package's own `latest`. Taking `latest` for a shared package like `@sanity/ui` puts two majors of it in one dependency tree, which produces duplicate-context errors and unstyled components that look like unrelated bugs.
### Step 6b: Assess plugins
Read `references/plugins.md` and follow it whenever the project has any plugin, which is nearly always.
Plugins block more upgrades than the Studio itself does, so they get their own pass and their own section in the report. Three things to carry into it: verify every plugin's compatibility rather than assuming it from who publishes it; let ownership decide the *path* once something is incompatible, not the verdict; and verify the *API shape* of any snippet you write against the package, separately from verifying its version, because a compatible version can still have renamed the export you are about to recommend.
### Step 7: Write the report
Follow `references/report-template.md` exactly. Write it to `SANITY-UPGRADE-PLAN.md` in the repository root unless the user names a different path.
Write a file rather than only printing to the terminal. The plan usually needs to reach people who are not at this keyboard: a lead, a reviewer, a support engineer. A file can go into a pull request or be pasted into a thread; terminal output cannot.
## Judgment calls worth making explicitly
**Recommend a sequence, not a single jump.** Crossing several majors in one commit makes failures impossible to attribute. Suggest intermediate stops at the last release of each major, and say why: it isolates each set of changes.
But a stop is only real if it is reachable. Each one needs its plugin versions resolved, because a plugin peering only recent majors can make an intermediate stop impossible to install. If you cannot resolve a stop, label it provisional rather than presenting the sequence as validated. Fewer verified stops beat more hypothetical ones.
**When you drop a stop, name what it costs.** Skipping one is often right: a boundary whose only applicable change is a Node floor is not worth a commit of its own, and a stop that cannot be reached without a forced duplicate major is worse than no stop. But the resulting hop then crosses two boundaries at once, and that is the exact thing the sequence exists to prevent. A plan that shows the evidence for skipping and stays silent on the consequence reads as though the trade were free.
So say both halves: why the stop was dropped, and that a failure in the combined hop could originate at either boundary, so debugging it means bisecting rather than reading the plan. One sentence covers it. The reader can accept the trade once they can see it.
**Size the work honestly.** If the project has two files importing `@sanity/ui` and no custom auth, say the upgrade is small. Inflating scope to look thorough wastes the reader's week. Equally, if the project imports from `sanity/_internal` or has a large custom component surface, say the upgrade is substantial rather than producing a plan that makes hard work look easy.
**Make the plan survive being skimmed.** It will be. A multi-boundary plan runs to several thousand words, and the person reading it is about to spend a week on the work, not an afternoon studying the document. So the top of the plan carries the whole thing in one screen, everything below it is reference to be read as the work reaches it, and findings are grouped by what the reader has to do rather than by which release the change came from. The specific failure to design against: a change that fails silently, sitting in a uniform table between two cosmetic ones, at identical visual weight. Length is not the enemy; flat weighting is.
**Write for the engineer who has to do the work, and describe the tree rather than the team.** This plan is usually read by someone at the customer, often someone who did not make the decisions that produced the current state. Keep the evidence, the specificity and the severity exactly as strong as the facts support: softening a real blocker or a real cost is a disservice, and an engineer can tell. What to drop is the implied verdict. "The deploy pipeline builds from the caret ranges rather than the lockfile" and "whoever set this up got it wrong" contain the same finding, and only one of them gets acted on. Reserve "you" for what to do next. Stay inside the upgrade, too: an unused dependency is not an upgrade finding, and a plan that drifts into general code review spends the reader's attention on things that do not gate the work.
**Name what you could not see.** Monorepos hide things: a `tsconfig.json` that extends a base file outside the repository, a workspace root that hoists React, a CI config in another directory. If a fact you needed was out of reach, put it in the human questions section by name. "I could not read `moduleResolution` because your tsconfig extends a file outside this repo" is useful. Silently omitting it is not.
**Do not promise roadmaps.** If asked whether a deprecated API will return or be removed, describe its current documented state and stop. Never speculate about future releases.
## Reference files
| File | Read when |
| --- | --- |
| `references/detect.md` | Always, at step 1 |
| `references/version-lookup.md` | Always, at step 2 and step 4 |
| `references/boundaries.md` | Always, at step 3. Index: coverage bounds and which boundary files to read |
| `references/boundary.v4.md` | Step 3, if the span crosses into or sits in v4 |
| `references/boundary.v5.md` | Step 3, if the span crosses into or sits in v5 |
| `references/boundary.v6.md` | Step 3, if the span crosses into or sits in v6 |
| `references/deprecations.md` | Step 3, always. Short, and not tied to a boundary |
| `references/package-coupling.md` | Always, at step 6 |
| `references/plugins.md` | Step 6b, whenever the project has plugins |
| `references/report-template.md` | Step 7 |