Directus Environment Sync RFC

Hi folks :waving_hand: – we wanted to share this working spec with you seeing as it’s one of the most requested features ever. Hopefully we can sharpen it together before our team starts developing this. So please post your questions and your feedback here on the thread.

Thanks for being part of the community and without further ado…

Directus Environment Sync RFC: Promoting Schema and Configuration Between Instances

Promote Directus configuration between instances with git as the audit trail. Model in the Studio, pull changes to files, review the diff in a PR, then push to the next environment.

This is a proposal. Nothing here is built yet. We are sharing the design early so the Directus community can tell us where it is unclear, unsafe, awkward, or missing something important.

Prior art and community tools

The Directus community has already built meaningful tooling in this space. directus-sync is the most established example - it provides a solid foundation for syncing Directus configuration between environments, and much of the design thinking in this proposal builds on patterns it has established. We want to acknowledge that work explicitly.

What this proposal adds is a first-party solution maintained by the Directus core team, with long-term support guarantees, and critically, improvements at the API level that make these capabilities more native to the platform. Rather than working around the existing API, we want to extend it so that the sync primitives are reliable foundations for any tooling in the ecosystem, including community projects.

How this relates to our roadmap

This proposal is our response to the heavily upvoted roadmap item “Env Sync | Config-as-Code”. We want to be upfront: this is not a fully code-first workflow in the way that other tools may implement, where schema and configuration are defined entirely in code and the database is derived from it.

That distinction is intentional. Our research, including community discussions, customer conversations, and discovery calls, surfaces a wide range of expectations for what “config-as-code” means in practice. But one finding is consistent across almost all of them: the core need is for improved, reliable tooling for auditable resource promotion, version control, peer review, and repeatability across schema, system resources, and item data. That is what this proposal solves.

A true code-first authoring workflow remains on our radar. We haven’t ruled it out. But the evidence points clearly to the above as the right priority to address now, and there is meaningful overlap: working with configuration files makes it easier to define resource shapes at the code level and apply them across environments, which is a step in that direction.

What we are proposing

Environment Sync has two pieces:

  1. A server API for comparing and applying schema, plus importing rows.
  2. A sync CLI tool that orchestrates those API calls and stores working state in .directus/sync/.

The team spec defines the API contract and CLI flow. This community draft also proposes a git-reviewable file workflow, production gates, and command names. Those product-layer details are open for feedback, not final API requirements.

The CLI is the main user experience:

directus sync pull --from staging      # instance -> json files
directus sync push --to prod           # json files -> instance

The intended workflow:

  1. Change your Directus project in the Studio.
  2. Run directus sync pull --from local or -from staging.
  3. Commit the generated directus/ files.
  4. Review the diff in a pull request.
  5. Run directus sync push --to staging or -to prod.
  6. Review the plan, confirm, and apply.

The committed files are meant to be readable enough that a one-field Studio change creates a small, reviewable git diff.

What syncs

This proposal separates four kinds of things:

Kind Examples Proposed default
Schema collections, fields, relations synced
Config resources flows, operations, roles, policies, permissions, dashboards, panels, settings, translations synced
Content your item data not synced
Users directus_users and user-specific records not synced

Config resources are stored as rows in Directus system collections, so the API treats them as data. The CLI still presents them as project configuration.

The team spec says data export happens when data is flagged. This draft proposes syncing config resources by default, while still allowing teams to include or exclude specific resource types. Dependency closure is default-on, but should be explicitly overridable.

This draft also proposes keeping content and users off by default because they are usually environment-specific and harder to match safely across instances.

Safe defaults

The bare command should be hard to misuse:

directus sync push --to prod

Proposed default behavior:

  • syncs schema and config resources
  • respects explicit include/exclude scope
  • leaves content alone
  • leaves user accounts alone
  • uses merge mode for imports, so it does not delete rows
  • uses the ID map to remap records already synced before
  • shows a plan and asks before writing

Important limitation: safe by default does not mean no-risk. merge can still overwrite in-scope target configuration. For example, if someone manually widened a field in production, the next sync can change it back to match the source. That is usually what you want when source-controlled config is authoritative, but it must be clear in the plan.

Modes

Mode controls how the target is treated. The team spec defines add and merge for import. The schema API also supports mirror

Mode Source record conflicts with target Already mapped / exists on both On target, absent from source
add data only created with remapped ID left unchanged left unchanged
merge default updates mapped target record, or creates with remapped ID if unmapped ID conflicts overwritten to match source left unchanged
mirror schema only same as merge for data overwritten to match source schema deleted; data left unchanged

mirror needs special care:

  • It can drop collections and fields.
  • Dropping a field drops the data in that column.
  • In v1, it does not delete config resources or content. A flow removed from the source survives on the target.
  • A full unscoped mirror is the only mode/scope combination that can drop whole collections.

If exposed in the CLI, power users could split schema and data modes:

directus sync push --to prod --schema-mode mirror --data-mode merge

First sync matching

The hardest part is the first sync into an environment that already has similar records.

Example: local and production both have an “Editor” role, but the records have different IDs. If the CLI only used source IDs, it could create a duplicate “Editor” role in production.

The team spec requires an ID map and notes that first sync duplicates may need CLI-level conflict resolution. One possible mitigation is reconciliation before import:

Resource type Matching strategy
System resources natural keys, such as role name, policy name, flow name, policy + collection + action for permissions
Junction rows remapped foreign-key pair
Content declared match key per collection, or create-as-new with a warning

Ambiguous matches are not guessed. If two target roles are both named “Editor”, the CLI should stop and ask.

Content has no universal natural key. Teams may need declared match keys or may need to accept that records can be created as new.

JSON files on disk

Product-layer proposal: pull writes a deterministic tree under directus/, and push reads it back. The team spec only requires working state under .directus/sync/.

directus/
├── schema/
│   └── collections/
│       └── articles/
│           ├── collection.json
│           ├── fields/
│           │   ├── title.json
│           │   └── body.json
│           └── relations/
│               └── author.json
├── flows/
│   └── publish-webhook.json
├── dashboards/
│   └── content-overview.json
├── policies/
│   └── editor.json
├── roles/
│   └── editor.json
├── translations.json
├── settings.json
└── content/
    └── posts.json

Rules:

  • JSON, stable key order, 2-space indent
  • volatile fields stripped
  • one file per selectable resource
  • dependent children inline where that is easier to review, such as operations inside a flow
  • filenames are human-readable slugs, not identity

Identity comes from the CLI’s working state and reconciliation, not from filenames. Renaming a file should not create a new target record if the resource is already mapped.

The CLI also keeps gitignored working state in .directus/sync/ for ID maps, cached snapshots, reconciliation results, and undo history.

Selection

You select whole resources, not their internal parts:

--collections articles,authors
--exclude-collections drafts
--roles editor
--exclude-flows nightly-cleanup
--content
--no-content

There is no --fields, --operations, or --panels flag. Those only make sense inside their parent resource. If you select a flow, you get its operations. If you select a dashboard, you get its panels.

The dependency graph does not mean every related system collection is always pulled. It describes what the CLI should include by default when selected records reference other records. For example, if you sync users, the CLI should pull their referenced roles, policies, and access rows by default so user records do not dangle. But that dependency pull should be opt-out: teams can choose to sync users without also syncing roles, policies, and access.

The plan still writes only what changed.

Production and CI

The API does not know what production is. The CLI owns that safety gate.

A profile can be marked as production:

{
  "profiles": {
    "prod": {
      "url": "<https://prod.example.com>",
      "production": true
    }
  }
}

For production targets:

  • interactive push always shows a plan and requires confirmation
  • push --yes is refused unless it uses a previously reviewed plan
  • there is no -force
  • -yes never opts into deletion; deletion still requires -mode mirror

Possible CI flow:

# PR: fail if prod would drift
- run: directus sync plan --to prod --json
  env:
    DIRECTUS_PROD_URL: ${{ secrets.PROD_URL }}
    DIRECTUS_PROD_TOKEN: ${{ secrets.PROD_TOKEN }}

# merge to main: apply reviewed plan
- run: directus sync plan --to prod --out plan.json
- run: directus sync apply --to prod --plan plan.json --yes

Undo and recovery

Product-layer proposal: before applying, the CLI snapshots affected resources into .directus/sync/<project>/history/. The team spec requires .directus/sync/<project>/id_map.json, schema cache, and data cache, but does not specify undo history.

directus sync undo --to prod

Undo is partial:

  • It can revert schema from the pre-apply snapshot.
  • It cannot un-overwrite existing rows changed by merge.
  • It cannot resurrect data dropped by a destructive schema change.

You still want and need database backups before any major changes (especially production).

Replacing directus-template-cli

The existing template CLI is additive seeding. Environment Sync keeps that workflow:

directus sync push --to <instance> --from ./template --mode add

directus-template-cli Sync CLI proposal
apply directus sync push --to <instance> --from ./template --mode add --content
extract / generate-template directus sync pull --from <instance>
additive-only loader add mode
no diff or dry-run directus sync plan

Existing templates remain usable as a source tree.

Known limitations

These are proposed v1 boundaries. Tell us which ones are dealbreakers.

  • First sync can create duplicates. The team spec calls this out directly. CLI-level conflict resolution may mitigate it, but it is not specified yet.
  • Content matching has no universal answer. User collections need declared match keys or create-as-new behavior.
  • Manually managed primary keys require merge. The primary key must be explicitly defined because the system cannot infer it.
  • No deletion of config resources or content. /import never deletes rows. mirror deletes schema only.
  • Schema and data are not one transaction. If schema apply succeeds and data import fails, the target may be partially migrated.
  • Data plans are advisory. Schema apply is hash-gated by the server. Data import has no equivalent server-side precondition in v1.
  • Undo is partial. Backups are still required for production safety.
  • No background imports. Large content syncs run synchronously in v1. Websockets and progress reporting are future improvements.

Questions for feedback

  1. Defaults: Should config sync by default while content and users stay off?
  2. Deletion: Is schema-only deletion enough for v1, or do removed flows/policies/content need deletion support immediately?
  3. Identity: Would you rather depend on client-side ID maps and natural-key reconciliation, server-stored sync_id, or UUID primary keys?
  4. Content matching: How would you expect Directus to identify the same content record across instances?
  5. Safety gates: Should production protections live in the CLI, the server, or both?
  6. Apply boundaries: Is separate schema/data apply acceptable for your deployment process?
  7. Commands: Are pull, push, plan, and apply the right surface?
  8. File layout: Would this tree produce useful PR diffs for your team?

Technical Appendix

The CLI is the recommended interface, but the design relies on a small set of API operations.

API pipeline

# Endpoint Side Effect
1 GET /schema/snapshot source reads collections, fields, relations
2 POST /schema/diff target computes schema changes and returns a target hash
3 POST /schema/apply target applies schema changes if the hash still matches
4 GET /items/* source reads config resources and optional content
5 POST /import target creates or updates rows, resolves dependencies, remaps IDs

Schema and data are separate writes. There is no single transaction across both.

Only schema can delete in v1. /import never deletes rows.

CLI flow from the team spec

The sync CLI keeps working state in .directus/sync/.

  • Profiles live in .directus/sync/profiles and represent connections.
  • Projects live in .directus/sync/projects and scope incremental sync tasks.
  • ID maps live at .directus/sync/<project>/id_map.json.
  • ID mappings are scoped by source, target, and collection so repeated syncs update the same target records.
  • Schema snapshots are cached in .directus/sync/<project>/schema.
  • Data exports are cached in .directus/sync/<project>/data.

The flow:

  1. Select or create a profile.
  2. Select or create a project.
  3. Retrieve the source schema snapshot.
  4. Store schema files and metadata.
  5. Generate a schema diff against the target.
  6. Apply schema changes.
  7. Export data if data was flagged.
  8. Create or reconcile the ID map.
  9. Import data with /import.
  10. Update the ID map from the import response.

Schema API

GET /schema/snapshot

GET /schema/snapshot
GET /schema/snapshot?includeCollections=articles
GET /schema/snapshot?excludeCollections=drafts,internal

Full snapshots are version: 1. Partial snapshots are version: 2.

Passing both includeCollections and excludeCollections is an error until glob support exists.

{
  "version": 2,
  "directus": "12.0.2",
  "vendor": "sqlite",
  "collections": [{ "collection": "articles", "meta": {}, "schema": {} }],
  "fields": [{ "collection": "articles", "field": "id", "type": "integer", "meta": {}, "schema": {} }],
  "systemFields": [],
  "relations": []
}

POST /schema/diff

Body is a snapshot. The response includes a hash that pins the target state.

{
  "hash": "4e4b34928b41ffc7f69456ff1f8005b0d97fdaa0",
  "diff": {
    "collections": [{ "collection": "articles", "diff": [{ "kind": "N", "rhs": {} }] }],
    "fields": [{ "collection": "articles", "field": "id", "diff": [{ "kind": "N", "rhs": {} }] }],
    "systemFields": [],
    "relations": []
  }
}

merge strips delete operations. mirror keeps them.

Scope controls how far deletes can reach:

Scope merge mirror
partial create/update only can drop fields and relations inside selected collections
full create/update only can drop fields, relations, and whole collections

POST /schema/apply

Applies the diff transactionally and refuses if the hash no longer matches the target.

The schema API supports merge and mirror. CLI-level add is implemented by filtering the plan before apply.

Import API

POST /import
POST /import/:collection

Parameters:

  • dry-run
  • mode=add|merge

Responsibilities:

  • create or update rows
  • never delete rows
  • resolve dependency order
  • defer nullable cyclical relations to a second pass
  • deconflict IDs
  • return the ID map for created or remapped records

Example body:

[
  { "collection": "authors", "items": [{ "id": "123", "name": "Lorem Ipsum" }] },
  { "collection": "articles", "items": [{ "id": "123", "title": "Hello World" }] }
]

Example response:

{
  "applied": true,
  "collections": {
    "articles": {
      "existing": ["5", "6", "7"],
      "new": ["101", "102"],
      "mapped": { "4": "101", "8": "102" }
    }
  }
}

POST /import/:collection returns 204.

Expected error states:

  • non-nullable circular dependency
  • missing foreign-key target

Dependency graph

The dependency graph describes default dependency closure for selected resources. It does not mean every related resource is always included. If selected records reference another resource, the CLI should include that dependency by default to avoid dangling references, but users can explicitly opt out of pulling those dependencies.

Open CLI proposal: dependency closure should be default-on and opt-out. If users are selected, referenced roles, policies, and access should come along by default, but teams should be able to disable that behavior when they intentionally want users only.

6 Answers

6

I like this idea. I did a quick read and decided that I need to read it again for possible better feedback. I do have two points on what I read so far:

  1. What does .directus mean (I also saw /directus btw). Is it a folder in your project? Is it in the user’s home directory (~/.directus)? Or both? Asking because of actual portability to VCS

  2. I noticed the folder structure for the files, consider this:

directus/
├── schema/
│   └── collections/
│       └── articles/
│           ├── collection.json
│           ├── fields/
│           │   └── title/
│           └── relations/
│               └── author/
+       └── Articles/  <-- now what?
+           ├── collection.json
+           └── fields/

This relates to “filenames are human-readable slugs, not identity” and probably partially to the feedback question “Would you rather depend on client-side ID maps and natural-key reconciliation, server-stored sync_id, or UUID primary keys?”

It leans towards an implementation detail and perhaps it’s already considered. If not still relevant though because it could imo impact the direction to choose so I am bringing it up here.

From experience with an extension we built for a similar flow as described in this RFC I know that this will break: almost all names in the database are case sensitive: collections, fields, relations, you name it. So you could have articles and Articles, but also fields like title and Title, or relations like author and Author in the same project. Oh, collections and folders are treated equally. In your schema they are both a Collection. An example: you might have a folder Organisations that also has a collection organisations in it.

You cannot assume case sensitivity for folder or file names, that does not work for I hope obvious reasons.

As a peek into how we solved it: append a hash to the name.

  • articlesarticles_1f8b5c94
  • Articlesarticles_0b4dad54
  • titletitle_abc12345
  • Titletitle_def67890

That’s it for now!

The acknowledgment at least worth a coffee?

Hi, just read through the rfc and here’s tome things that came into my mind:

  • .directus/ folder: Imho when using a git-as-SSOT workflow, it shouldn’t be in a hidden folder
  • ID-Mapping: Generally I think it should store mapping in the db, so that it e.g works flawlessly for multiple devs / machines with the same instance (e.g staging), as this is bound to the instances… Local files could easily mess with multiple instances (assuming there isn’t a scoped file per instance)
  • Not fully sure about the profiles: Is it possible to define any arbitrary amount of profiles and use these via cli? (highly needed. based on config format I assume it’s already covered, but just to be sure…)
  • Undo and recovery: as there’s no way to recover all changes, imho that’s a false sense of security and shouldn’t be added at all… Users should rely on a db backup instead.
  • Modes --> merge --> On target, absent from source: left unchanged Shouldn’t absent entries be removed on a merge?
  • translations folder: I’d suggest to split it up into language-based files.. This way it’s also easier for AI / external tooling based translations on the JSON-Fils and re-sync them…
  • Instead of having a `plan` command I’d like to suggest using an interactive mode with an `diff` or `dry-run` flag by default.
  • First sync can create duplicates: therefore it could use a config-file where users can define based on what field it should map and then store _sync_ids like directus-sync does (same for Manually managed primary keys require merge) or simply ask on sync and then store the answers in the file…
  • No deletion of config resources or content: After a full sync I’d expect the resources (tables / columns) to be removed, same for content (flows / dashboards)
  • Commands: What is apply for? What’s the difference from push?
  • includeCollections / excludeCollections: for cli level I’d expect them to be available as config and a cli flag

In general not sure if a distinction with the three modes (add, merge and mirror) is needed at all…I get that it should target different user-groups, but with the files as SSOT, I’d expect a git-based workflow where I manually check / resolve conflicts before applying again. The different behavipurs can easily lead to issues, unexpected changes and / or confusion…

But all in one a great rfc and really looking forward to a native solution :tada:

This is indeed a much needed change that kept me from using directus in bigger projects with larger teams.

I skimmed through this quickly and something that I was looking for is how to integrate this properly with migrations.

The bigger issue I am facing now is to decide on, should we maintain the schema in migrations or in studio?

Studio alone is not enough as we need to run things like creating indexes or other things that are not supported in the studio.

But the current flow is to run migrations before the schema, which means we can’t apply migrations/indexes/constraints unless we create the needed fields in the migration itself.

Which means we now have 2 places to maintain the schema, which is not backward compatible and could break future builds.

We don’t have a problem with syncing envs as current community plugins did a great job of creating a good solution, and migrating this to be a native directus feature is absolutely useful. But I still think the bigger problem is creating a migration/schema/code marriage that makes production pipelines more resilient + make it easier for AI agents to play with directus schema and apply changes/migrations.

Looking forward to the future!

I’m very excited to see traction on this issue, it’s something I’ve been hoping for for years and it’s great to see the thought going into it. :two_hearts:

Reading through the RFC, It might be worth looking at Grafana’s new Git Sync integration for design inspiration. Funny that you’re both coming to this around the same time.

What Grafana does is they configure (one or more) Repositories with Github App credentials so that when you save a dashboard, those changes get committed directly to Github. And there is an automatic reconciliation loop that will pull changes from the repo. For the purposes of data safety, you could have manual-approval guards (e.g. somewhere in the settings navigation bar, with a little badge) that protect from accidental collection/field deletion or other unwanted operations and allow you to manually resolve conflicts.

Merging of environments is handled using GitHub branches or separate folders, ideally, you’d be able to configure multiple repositories so that collections/flows could be modularized into their own apps with responsibilities.

It looks like the proposed model handles a lot of this (through CLI calls/CI), so what I’m suggesting is an automated imperative push (on save) and a polling declarative pull (with partial or full merge approval required as needed). Using automated syncing also reduces the risk of merges overwriting in-scope target configuration (see “Safe defaults”) because it keeps git closer to the source of truth.

Anyways, you should definitely at least check out how Grafana does Git Sync because it would be great if apps had a consistent design language for these kinds of features.

Some quick links:

Cheers!

First off — thank you for putting this together. Environment sync is the capability we’ve most wanted to see land natively, and the pipeline you’ve sketched is very close to how we already work.

For context on where we’re coming from: we run Directus as a secondary store behind a .NET platform, drive schema and config across local/dev/prod with directus-sync today, and carry a fairly large custom schema (a few hundred custom collections) plus a multi-tenant layer where a standard configuration is shared centrally and individual customers extend it. So we’ve lived in exactly the workflow this RFC targets. Below is one bug we think is load-bearing for the design, then four shorter notes, and direct answers to your questions at the end.

1) A data-loss bug in POST /schema/apply that the native feature would inherit

This is the most important item in this comment. The proposed pipeline applies schema through POST /schema/apply with a diff-hash pin — which is the right shape — but there’s a pre-existing bug in that endpoint that can turn a harmless metadata change into a dropped table.

What we saw. While promoting schema between environments, a single apply dropped every custom collection — a few hundred of them — on a local instance, along with their data. (Local instance, not customer production.) The only table that survived was directus_sync_id_map, because directus-sync filters it out of the diff. The API returned 204 No Content, so nothing signalled that anything destructive had happened.

Why it happens. apply-diff.ts treats collections and fields asymmetrically. The field delete branch guards itself with !isNestedMetaUpdate(...), so a nested meta.* change is routed to an update, not a delete. The collection delete branch has no such guard — it fires a full delete whenever the first diff entry is a DELETE, whether that entry is a whole-collection delete or just a nested meta-property delete like { kind: 'D', path: ['meta', 'status'] }:

  • collection delete — no guard: api/src/utils/apply-diff.ts lines 110–142 (and the pre-filter that feeds it, lines 182–186)
  • field delete — guarded: same file, lines 255–277
  • isNestedMetaUpdate: same file, lines 374–379

(Commit-pinned permalinks for all of these are in the linked issue below.)

So a diff whose first entry for a collection is a nested-meta delete is misclassified as “drop this collection” → collectionsService.deleteOne(...)DROP TABLE with its data. This is present on current main (@directus/api 37.0.1); we originally hit it on 11.16.1, so it has been there for a while.

What produces such a diff in practice: migration skew. Two instances can both report the same Directus version while having different directus_migrations. When one side has run newer migrations that add columns to directus_collections (for us these were autosave_revision_interval and status), those keys appear in the snapshot of every collection. Diffing that snapshot against an instance that doesn’t have them yet yields a nested-meta D entry per collection — and with the bug above, that’s a drop per collection. The gentle takeaway: a version string is not a reliable compatibility signal; the migration ledger is.

Honest caveat. The code asymmetry is definitive (quoted above), and we reproduced the end result empirically (the incident). We did not trace the snapshot-diff generator line by line to prove that a single meta-property removal always surfaces as a top-level D rather than an E. That said, issue #25760 documents exactly this class of change — since v11.10.0, meta-only changes can surface as top-level N/D collection entries. Our drop is the D-shaped mirror image of the spurious create reported there.

Why this matters for the RFC specifically. The native apply inherits this primitive. The diff-hash pin protects against drift and races between diff and apply — it does not protect against a diff that is itself misclassified. It also undercuts the safety model the RFC describes: mirror promises “schema-only deletion, data left unchanged,” and merge promises never to delete target-only records — yet this bug drops a populated table from a nested-meta diff, and it can fire under merge, which is meant to be delete-free. Every gate you list (production confirmation, no --force, --yes never opting into deletion, deletion only under --mode mirror) assumes deletion is intentional. This deletion isn’t intentional; it’s a misclassification, so none of those gates would catch it.

Fix. It’s essentially a one-liner: give the collection delete branch the same !isNestedMetaUpdate(diff?.[0]) guard the field branch already has (and the pre-filter at L182–L186). Genuine whole-collection deletes (root D, no nested meta path) are unaffected, so it’s symmetric and safe. I’ve written up a separate issue + PR with a minimal repro, the exact commit-pinned source permalinks, and the guard — #27877 — since that’s the better channel for the fix itself. Flagging it here because it directly constrains this design.

2) A few shorter notes from the same workflow

a. A pre-apply skew guard (relevant to Q5 and Q2). Beyond version/hash checks, it would help to compare the two sides’ migration state — or, more cheaply, the set of meta property keys present in each snapshot — as a precondition to apply, and to refuse (or require explicit opt-in) when they diverge. More broadly: destructive operations shouldn’t be derived implicitly from a diff at all; a collection or field deletion should require explicit intent. This is adjacent to the migration point abol3z raised above, but from the other direction — cross-instance migration skew rather than authoring schema in two places.

b. Field-level ownership for multi-tenant (relevant to Q2). The add/merge/mirror trichotomy is instance-global, which doesn’t quite fit a multi-tenant setup. We ship a standard configuration centrally, and individual customers add fields to standard collections and maintain their own collections. On a standard-config promote, those customer-owned fields and collections must be neither overwritten nor deleted — and they shouldn’t block the rollout either. A “managed set” — an ownership manifest, or a marker on managed resources — would let sync touch only what it explicitly manages and ignore everything else, rather than treating it as drift to remove. This also speaks to the discussion above about whether merge should delete absent entries: for us the answer depends on ownership, not on a global mode.

c. Favour UUID primary keys for identity (answering Q3). Of the three options you list, we’d lean toward UUID PKs. Directus already lets you pin them where it matters most: POST /files and POST /files/import accept a client-provided id (a UUIDv4), and directus_folders and any custom collection with a UUID PK do too — so stable IDs across instances are achievable without a mapping table for those resources. (Setting a PK on create for arbitrary items appears to work in practice but isn’t spelled out in the docs, so treat that part as “by behaviour.”) Client-side ID maps have been a recurring source of friction for us — settings sync-id errors, and a 503 when the map row is created on the fly. This is a different angle on Dominic_M’s point about storing the map server-side, and it’s reinforced by Jogchum’s case-sensitivity findings: both are ultimately arguments against keying identity on names.

d. Content with declared natural keys (answering Q4). The RFC already allows content opt-in via --content and asks how to match records across instances. The “no universal natural key” concern is fair as a default, but many config-adjacent collections do have a stable business key — mail templates by template key, translations by translation key, and so on. An opt-in “content package” with a per-collection declared natural key would cover exactly the collections teams actually want to carry on an environment promote, without pretending a universal key exists. This extends Dominic_M’s config-defined mapping-field suggestion into a first-class, per-collection declaration.

3) Direct answers to your questions

Where we have a view:

  • Q2 (deletion): Schema-only deletion is fine for v1 — provided destructive operations are never derived implicitly from a diff. The bug in §1 is the cautionary tale.
  • Q3 (identity): UUID PKs > server-stored sync_id > client-side ID maps, for the reasons in §2c.
  • Q4 (content matching): Opt-in content packages with a per-collection declared natural key (§2d).
  • Q5 (safety gates): Both, but the server must be the enforcer. CLI-only gates don’t protect the many non-CLI callers of /schema/apply — Studio, directus-sync, and this feature itself. Concretely: the server should refuse to derive a collection/field deletion from a nested-meta diff, and should require an explicit destructive-intent flag for real deletions.
  • Q6 (separate schema/data apply): Acceptable for us. The one caveat is that a non-atomic schema-then-data apply means a mid-run failure can leave a dropped table with no data to restore — another reason not to drop implicitly.

Thanks again for opening this up. Happy to help test against a large real-world schema, or to share more detail on the skew scenario if that’s useful.