diff --git a/app/globals.css b/app/globals.css
index a130fd32..67a603c0 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -164,6 +164,10 @@ html.dark-mode {
justify-content: flex-start !important;
text-align: left !important;
}
+/* Nextra's inner content wrapper adds its own `py-2.5`. */
+.nextra-banner > div:has(> .docs-announce) {
+ padding: 0 !important;
+}
.docs-announce {
display: flex;
flex-wrap: wrap;
@@ -193,6 +197,20 @@ html.dark-mode {
color: hsl(28 100% 72%); /* sand-300 */
flex-shrink: 0;
}
+/* The glyphs come from a fallback font with differing advance widths, so fix
+ the box width or the message jitters sideways while the cursor animates. */
+.docs-announce .da-spinner {
+ width: 1em;
+ text-align: center;
+ font-size: 1.5em;
+ /* Share the text's line box (0.8125rem × 1.4) so the cursor tracks the
+ first line when the message wraps. */
+ line-height: calc(0.8125rem * 1.4);
+ align-self: flex-start;
+ /* The fallback glyph draws low in its box; center its ink on the text. */
+ position: relative;
+ top: -0.15em;
+}
/* terminal cursor — the cli-spinners "hamburger" (☱☲☴). Static by default,
animates only during the load burst or on hover (`.spin` toggled by the JS). */
.docs-announce .da-spinner::before {
diff --git a/app/spicedb/_meta.ts b/app/spicedb/_meta.ts
index 98632821..0b07d7e7 100644
--- a/app/spicedb/_meta.ts
+++ b/app/spicedb/_meta.ts
@@ -6,6 +6,7 @@ export default {
integrations: "Integrations",
tutorials: "Tutorials",
"best-practices": "Best Practices",
+ "migrate-to-spicedb": "Migrate to SpiceDB",
api: "API Reference",
links: "Links",
};
diff --git a/app/spicedb/getting-started/discovering-spicedb/page.mdx b/app/spicedb/getting-started/discovering-spicedb/page.mdx
index e5344de6..038175ea 100644
--- a/app/spicedb/getting-started/discovering-spicedb/page.mdx
+++ b/app/spicedb/getting-started/discovering-spicedb/page.mdx
@@ -4,7 +4,7 @@ description: "Overview of SpiceDB, the open-source Google Zanzibar-inspired data
---
import { Cards } from "nextra/components";
-import { faQuestion, faHand, faDollarSign } from "@fortawesome/free-solid-svg-icons";
+import { faQuestion, faHand, faBook, faTruckMoving } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
# SpiceDB Documentation
@@ -26,10 +26,16 @@ Welcome to the official documentation for the SpiceDB ecosystem.
/>
}
- title="Paid products? That's the AuthZed docs"
+ icon={}
+ title="AuthZed product docs"
href="../../authzed/guides/picking-a-product"
/>
+ }
+ title="Migrating to SpiceDB"
+ href="/spicedb/migrate-to-spicedb/overview"
+ />
## What is SpiceDB?
diff --git a/app/spicedb/migrate-to-spicedb/_meta.ts b/app/spicedb/migrate-to-spicedb/_meta.ts
new file mode 100644
index 00000000..3fc4a567
--- /dev/null
+++ b/app/spicedb/migrate-to-spicedb/_meta.ts
@@ -0,0 +1,4 @@
+export default {
+ overview: "Overview",
+ "migrate-from": "Migrate From",
+};
diff --git a/app/spicedb/migrate-to-spicedb/migrate-from/_meta.ts b/app/spicedb/migrate-to-spicedb/migrate-from/_meta.ts
new file mode 100644
index 00000000..3d2dad95
--- /dev/null
+++ b/app/spicedb/migrate-to-spicedb/migrate-from/_meta.ts
@@ -0,0 +1,4 @@
+export default {
+ openfga: "OpenFGA/Okta FGA",
+ oso: "Oso",
+};
diff --git a/app/spicedb/migrate-to-spicedb/migrate-from/openfga/page.mdx b/app/spicedb/migrate-to-spicedb/migrate-from/openfga/page.mdx
new file mode 100644
index 00000000..5873604e
--- /dev/null
+++ b/app/spicedb/migrate-to-spicedb/migrate-from/openfga/page.mdx
@@ -0,0 +1,282 @@
+---
+title: "Migrate from OpenFGA to SpiceDB"
+description: "OpenFGA, Okta FGA, and Auth0 FGA specifics for migrating to SpiceDB: how models and conditions map, the decisions to expect, extracting tuples, client call differences, and converting tests."
+---
+
+import { Callout } from "nextra/components";
+
+# Migrate from OpenFGA to SpiceDB
+
+OpenFGA, Okta FGA, and Auth0 FGA share the same modeling language, so the guidance here applies to all three, and Okta FGA's differences are limited to a few API calls.
+Read the [migration overview](/spicedb/migrate-to-spicedb/overview) first for the general process.
+
+
+ AuthZed's `spicedb-dev` AI agent plugin can automate an OpenFGA migration. To install and run it,
+ see [Build with your AI agent](/spicedb/getting-started/build-with-an-agent#migrating-to-spicedb).
+
+
+OpenFGA and SpiceDB are both based on Google's Zanzibar, so most of a model translates directly.
+The main structural difference is that OpenFGA uses one kind of name, `define`, for both stored and computed access, while SpiceDB separates them into relations and permissions.
+
+## Planning
+
+### Find your complete model
+
+OpenFGA models live in more places than `.fga` files: inline in a `.fga.yaml` store file, as authorization-model JSON, as a modular `fga.mod` manifest with several files, embedded as a string in application code, or only in a live store.
+If your SDK is a dependency but you can't find a model file, it's probably embedded in code.
+When a DSL file and a generated JSON copy both exist, confirm they match, since the JSON is usually what reaches production.
+
+### What drives the cost
+
+A few things predict most of the effort:
+
+- **How many `define`s mix a type list with an operator.** Each becomes a [relation split](#the-relation-split), which changes stored data and call sites.
+- **Contextual tuples.** They don't appear in the model at all, and they're the largest source of unplanned work.
+- **How many stores you use.** More than one store means a tenancy decision.
+- **Whether you pin authorization model IDs.** SpiceDB has no equivalent.
+- **Your test assertions.** Only `check` assertions convert directly, so count `list_objects` and `list_users` assertions separately.
+
+### How the model maps to SpiceDB
+
+| OpenFGA | SpiceDB | Notes |
+| ------------------------------------------------------ | ---------------------------------------------- | --------------------------------------------- |
+| `model` and `schema 1.1` header | Omitted | |
+| `define viewer: [user, group#member]` | `relation viewer: user \| group#member` | |
+| `define viewer: [user:*]` | `relation viewer: user:*` | |
+| `define viewer: [user with cond]` | `relation viewer: user with cond` | |
+| `a or b`, `a and b`, `a but not b` | `(a + b)`, `(a & b)`, `(a - b)` | Always parenthesize |
+| `member from parent` | `parent->member` | The operand order reverses |
+| `condition c(p: int) { ... }` | `caveat c(p int) { ... }` | A few parameter types need body changes |
+| A `define` mixing a type list with an operator | A `relation` plus a `permission` | See [the relation split](#the-relation-split) |
+| `module` and `extend type` | Composable schemas with `partial` and `import` | See [modular models](#modular-models) |
+| A condition comparing a grant time to the current time | Native expiration, or a caveat | See [conditions](#conditions) |
+
+## Decisions to expect
+
+### Contextual tuples
+
+Contextual tuples are relationships passed with a request and never stored.
+SpiceDB's per-request data is [caveat context](/spicedb/concepts/caveats), which carries values rather than relationships, so each call site that passes contextual tuples needs its own decision:
+
+- **Store the relationships** if the "temporary" edge is really durable state. This is often the best answer.
+- **Re-model the tuple as caveat context** if it really carries a value, such as an IP address.
+- **Write the relationships around the check and remove them afterward,** which is correct but adds latency and cleanup.
+
+Search the whole repository for them, including `.fga.yaml` test files, since a test that passes contextual tuples implies production code that does too.
+Storing or re-modeling them can create data you'll need to [keep in sync](/spicedb/migrate-to-spicedb/overview#sync-obligations).
+
+### Multiple stores
+
+A SpiceDB instance holds one schema and one relationship graph, so if you use several OpenFGA stores, you'll need to choose a [tenancy approach](/spicedb/migrate-to-spicedb/overview#cross-cutting-decisions).
+Code that creates stores at runtime is tenant provisioning, and needs rewriting to match.
+If every extra store ID you find is test scaffolding, this doesn't apply.
+
+Even with one store, watch for types such as `role` or `group` with no relationship back to their tenant.
+Their isolation depends on your application never writing a cross-tenant relationship, in OpenFGA and SpiceDB alike.
+
+### Model ID pinning
+
+SpiceDB always serves the current schema, and schema changes take effect immediately.
+Rolling out a change is managed through your deployment process instead, as a series of additive steps: add new relations, backfill, move readers over, then remove the old ones.
+Pinning one model ID in config is usually just habit and can be dropped, but per-request pinning is often a rollout mechanism that something depends on, and needs a replacement plan.
+
+### Embedded OpenFGA server
+
+Some applications import OpenFGA as a Go library and run it in-process against their own database.
+SpiceDB can also be embedded as a Go library, so you can keep the in-process shape, or take the opportunity to run SpiceDB as a separate service.
+Either way, extract tuples from the embedded store's own database tables, since the `fga` CLI assumes a reachable server.
+
+### Transitive wildcards
+
+OpenFGA lets a relation include a userset, such as `[team#member]`, whose relation itself allows a wildcard like `[user:*]`.
+SpiceDB rejects this.
+The usual fix is to add a permission alias for the intermediate relation and point the userset at it.
+Other options are to move the wildcard onto the outer relation, which makes it unconditionally public, or to drop the wildcard and grant subjects individually.
+
+## Converting the schema
+
+### The relation split
+
+OpenFGA uses `define` for both stored and computed access, so a `define` that mixes a type list with an operator becomes two names in SpiceDB:
+
+```
+define viewer: [user, group#member] or editor or editor from parent
+```
+
+```zed
+relation viewer__direct: user | group#member
+permission viewer = (viewer__direct + editor + parent->editor)
+```
+
+The permission keeps the original name, so checks, other permissions, and test assertions keep working.
+Every stored `viewer` tuple, though, is now written to `viewer__direct`.
+A `define` with only a type list stays a plain relation, and one with no type list becomes a plain permission.
+
+The name depends on where it appears:
+
+| Where | OpenFGA | SpiceDB |
+| ---------------------- | --------------------------------- | ----------------------------------------- |
+| Writing a relationship | `organization:o#member@user:erik` | `organization:o#member__direct@user:erik` |
+| A userset subject | `...@organization:o#member` | `...@organization:o#member` |
+| A check or assertion | `member` on `organization:o` | `organization:o#member` |
+
+Writing to the permission returns an error.
+Checking the `__direct` relation is allowed, but returns only directly granted subjects, so that mistake produces wrong answers with no error.
+
+### Permission names
+
+Because OpenFGA doesn't distinguish relations from permissions, a split permission keeps a role noun as its name, such as `permission owner` rather than `permission own`.
+Leaving names as they are is usually safest, since other services, dashboards, or configuration may reference them by string.
+
+### Arrows and precedence
+
+`member from parent` becomes `parent->member`, with the operands reversed, which is an easy translation mistake.
+SpiceDB also recommends that arrows point at permissions, so where an arrow's target is a relation, add a permission alias on the target and point the arrow at it.
+If the arrow's relation allows more than one type, such as `relation parent: drive | folder`, the target name has to exist on every one of those types.
+A missing alias on one type isn't reported as a warning, and checks through that type return `false`, so review these by hand.
+
+SpiceDB's operator precedence differs from what you might expect: union binds tighter than intersection, so `a + b & c` means `(a + b) & c`.
+OpenFGA already requires parentheses when mixing operators, so keep one parenthesized group per source expression.
+
+### Names
+
+SpiceDB names must be lowercase, 3 to 64 characters, and use only letters, digits, and underscores, as described under [identifiers](/spicedb/migrate-to-spicedb/overview#identifiers).
+OpenFGA names with uppercase letters, hyphens, dots, slashes, a leading underscore, or fewer than three characters need renaming.
+SpiceDB also reserves some words, including `relation`, `permission`, `definition`, and `caveat`.
+Watch for names that collide once normalized, such as `can-edit` and `can_edit`.
+
+### Caveats
+
+Most OpenFGA conditions carry over with only a change to the parameter declaration syntax.
+Two parameter types need body changes:
+
+- **`uint`** values are treated as `int` inside a SpiceDB caveat expression, so drop the `u` suffix from literals, writing `x > 0` rather than `x > 0u`.
+- **`ipaddress`** values can't be constructed from a literal in the body. Compare against a CIDR with `.in_cidr()` instead, using a `/32` or `/128` prefix for an exact address.
+
+SpiceDB also rejects parameters the expression doesn't use.
+In both systems, indexing a map with a missing key is an evaluation error, so guard map lookups where the key may be absent.
+
+### Conditions
+
+Some condition shapes have more than one valid SpiceDB encoding:
+
+- **Time-limited grants** are best expressed with [native expiration](/spicedb/concepts/expiring-relationships), which has no per-check cost and removes expired relationships from every read immediately. Use a caveat instead if any call site needs to ask "as of" a different time, such as an audit query.
+- **Per-request values,** such as a source IP or current usage against a quota, are best kept as caveats. Replacing them with a stored marker reflects the state at the last write rather than the current request, which for a security boundary is a different guarantee.
+
+### Customer-defined roles
+
+Customer-defined roles work without any special construct.
+A `role` type with an `assignee` relation can be granted any permission that lists `role#assignee` among its allowed types, so creating a new role and granting it a permission is just relationship writes, with no schema change.
+A role that needs to grant something the schema didn't anticipate still needs a schema change.
+
+### Modular models
+
+OpenFGA merges `fga.mod` modules into one model.
+In SpiceDB, each `extend type` becomes a [partial](/spicedb/modeling/composable-schemas), and a root file imports each module and combines the partials into the base definitions.
+`WriteSchema` doesn't accept imports, so compile the modules into one file with `zed schema compile` before deploying, and validate the compiled result.
+
+## Extracting and loading tuples
+
+The `fga` CLI's defaults stop early without warning, so check them before trusting an export:
+
+- `fga tuple read` follows a limited number of pages by default. Pass `--max-pages 0` to read everything.
+- `fga store export` stops at 100 tuples by default, and `--max-tuples 0` means zero, not unlimited, so use it only for small stores.
+- The CLI doesn't give you a continuation token to resume from, so for an extraction that has to resume across runs, use the `Read` API directly.
+
+A tuple with a condition carries it as a nested `condition` object holding a `name` and `context`.
+A transform that looks for the condition in the wrong place will load every conditional grant as unconditional, and count-based verification won't catch it.
+
+OpenFGA tuples don't say which relations were split or which IDs need encoding, so the transform has to apply your recorded mappings: write to the `__direct` relation where a split exists, and keep the permission name when it appears in a userset subject.
+
+A few load details:
+
+- Stored caveat context is limited to 25,000 bytes per relationship, and context supplied with a check to 4,096 bytes.
+- The `zed relationship` batch commands apply one caveat to every line, so load caveated tuples with a client library instead.
+
+To catch writes made during the migration, capture a change token with `fga tuple changes` before extraction, and replay changes from it after the load.
+
+### Identifiers
+
+OpenFGA accepts IDs such as `user:alice@corp.com` that SpiceDB doesn't, so emails and similar IDs need [encoding](/spicedb/migrate-to-spicedb/overview#identifiers).
+Share one encoding module between the data load and your application code, since two slightly different encodings produce permanent `false` results.
+If you use base64url, be consistent about padding, because most languages offer both padded and unpadded variants.
+Also look for escaping helpers in your application, such as one that percent-encodes `/`, since they produce characters SpiceDB doesn't allow and won't show up in fixtures.
+
+## Updating application code
+
+The OpenFGA SDKs come in a few shapes, sometimes within one codebase: `OpenFgaClient`, the lower-level `OpenFgaApi`, and the older `Auth0FgaApi`.
+Convert each call site based on the shape it uses.
+
+| OpenFGA | SpiceDB | Notes |
+| -------------------------------------- | ----------------------------------- | --------------------------------------------------------- |
+| `check` | `CheckPermission` | |
+| `batchCheck` | `CheckBulkPermissions` | Results are in request order, not keyed by correlation ID |
+| `listObjects`, `streamedListObjects` | `LookupResources` | Always streams; use the permission name |
+| `listUsers` | `LookupSubjects` | Use the permission name |
+| `listRelations` | A bulk check across the permissions | |
+| `expand` | `ExpandPermissionTree` | Returns a fully resolved tree in one call |
+| `read` | `ReadRelationships` | Use the relation name |
+| `write`, `writeTuples`, `deleteTuples` | `WriteRelationships` | Use the relation name |
+| `readChanges` | `Watch` | A stream rather than a poll |
+| `writeAuthorizationModel` | `WriteSchema` | Replaces the live schema |
+| `readLatestAuthorizationModel` | `ReadSchema` | |
+
+Differences worth knowing:
+
+- **Duplicate handling.** OpenFGA's default write fails on an existing tuple, which corresponds to SpiceDB's `CREATE`. Ignoring duplicates corresponds to `TOUCH`.
+- **Non-transactional writes.** OpenFGA can write in chunks with partial success, while each SpiceDB write call is atomic. Decide whether to keep all-or-nothing behavior or write individually.
+- **`listRelations` error handling** varies by SDK version and language on both sides, so decide deliberately what a single failure should do rather than letting it become a denial.
+- **Wildcard subjects in checks.** OpenFGA accepts `*` as a check subject and SpiceDB doesn't. For a relation with only direct assignments, check whether the wildcard relationship exists instead. For anything computed, there's no equivalent, so it needs a design decision.
+- **`readChanges` to `Watch`** changes the calling code's structure, since Watch resumes from a revision token rather than a timestamp.
+- **Calls with no SpiceDB equivalent,** such as store management and `readAssertions`, need to be handled according to your tenancy and testing decisions.
+
+### Consistency
+
+SpiceDB lets every request choose how fresh its answer must be, from the fastest cached result, to one at least as recent as a given write, to one computed from the very latest data, and it's designed to prevent the [New Enemy Problem](/spicedb/concepts/zanzibar#new-enemy-problem).
+OpenFGA's two consistency preferences cover only part of that range, so they don't line up one-to-one with SpiceDB's [consistency levels](/spicedb/concepts/consistency#levels).
+The nearest matches are full consistency for `HIGHER_CONSISTENCY` and minimized latency for `MINIMIZE_LATENCY`, which is fine for a check that doesn't depend on a recent write.
+For one that does, such as creating a resource and checking it on the next request, pass the [ZedToken](/spicedb/concepts/consistency#zedtokens) from the write, which OpenFGA had no equivalent for.
+The same applies to lookups, where a stale answer is a short or empty list.
+See [Read-After-Write Consistency](/spicedb/concepts/read-after-write).
+
+## Converting tests
+
+Your `.fga.yaml` files convert into SpiceDB validation files:
+
+| `.fga.yaml` | SpiceDB validation file |
+| ------------------------------------------ | ------------------------------------------------ |
+| `model` or `model_file` | `schema` or `schemaFile` |
+| `tuples`, at the root or in `tests` blocks | `relationships` |
+| `check` assertions | `assertTrue` and `assertFalse` |
+| `check.context` | A `with {...}` suffix on the assertion |
+| A tuple's `condition` | A `[name:{...}]` suffix on the relationship |
+| `list_objects` and `list_users` | No equivalent; verify against a running instance |
+
+Things to watch:
+
+- **Tuples use the relation, and assertions use the permission.** A file with the two swapped still validates, while testing the wrong thing.
+- **Collect tuples from every `tests` block,** not just the root, or you'll get an empty set of relationships.
+- **`tests` blocks share one set of relationships.** OpenFGA isolates each block, but a validation file doesn't, so blocks whose data conflicts, such as a document that's `draft` in one test and `published` in another, need separate files.
+- **Cumulative tutorial-style files** (`step-1`, `step-2`, and so on) are common, so pick the most complete one deliberately.
+
+## Running both systems side by side
+
+The [side-by-side comparison](/spicedb/migrate-to-spicedb/overview#run-both-systems-side-by-side) on the overview applies as described.
+A few OpenFGA specifics:
+
+- **Translate observed calls through your mappings,** so a check on `viewer` in OpenFGA compares against the `viewer` permission in SpiceDB, never `viewer__direct`.
+- **Count SDK exceptions as errors, not denials,** including a condition missing required context. Observe calls at the SDK boundary, before any application code that defaults failures to `false`.
+- **OpenFGA never returns a conditional answer,** so a SpiceDB answer that depends on missing context is a coverage gap rather than a disagreement.
+- **Compare list results as sets.** Non-streaming `listObjects` stops at 1,000 results by default, so sample from `streamedListObjects`, and remove duplicates from SpiceDB's results before comparing.
+
+## Gotchas
+
+- **Writes go to `viewer__direct`, and checks stay on `viewer`.** See [the relation split](#the-relation-split).
+- **Arrow operands reverse, and union binds tighter than intersection.**
+- **Every type an arrow can traverse needs the target name,** or checks through the missing type silently return `false`.
+- **Contextual tuples don't appear in the model.** Search the whole repository, including `.fga.yaml` files.
+- **IDs containing `@`, `.`, or `%` need encoding,** and `uint` and `ipaddress` caveat bodies need changes.
+- **`fga tuple read` needs `--max-pages 0`, `fga store export` stops at 100 tuples, and a tuple's `condition` is a nested object.**
+- **`batchCheck` results pair by position** in SpiceDB, not by correlation ID.
+
+See the [schema language reference](/spicedb/concepts/schema) for SpiceDB's side of every construct on this page.
diff --git a/app/spicedb/migrate-to-spicedb/migrate-from/oso/page.mdx b/app/spicedb/migrate-to-spicedb/migrate-from/oso/page.mdx
new file mode 100644
index 00000000..4ac6e05a
--- /dev/null
+++ b/app/spicedb/migrate-to-spicedb/migrate-from/oso/page.mdx
@@ -0,0 +1,284 @@
+---
+title: "Migrate from Oso to SpiceDB"
+description: "Oso Cloud specifics for migrating to SpiceDB: how Polar policies and facts map, the decisions to expect, exporting facts, SDK call differences, and converting Polar tests."
+---
+
+import { Callout } from "nextra/components";
+
+# Migrate from Oso to SpiceDB
+
+This guidance applies to **Oso Cloud**, where a Polar policy and a store of facts are queried over Oso's API.
+Read the [migration overview](/spicedb/migrate-to-spicedb/overview) first for the general process.
+For a conceptual comparison of the two systems, see [SpiceDB for Oso users](/spicedb/getting-started/coming-from/oso).
+
+If you use the deprecated open-source `oso` library instead, the policy guidance below still applies, since its policies are also Polar.
+Its data lives in your application's own objects rather than as facts, though, so the data stage becomes a custom extraction from your database.
+
+
+ AuthZed's `spicedb-dev` AI agent plugin can automate an Oso Cloud migration. To install and run
+ it, see [Build with your AI
+ agent](/spicedb/getting-started/build-with-an-agent#migrating-to-spicedb).
+
+
+Oso's declarative core translates almost mechanically: a `has_role(User{"alice"}, "steward", Lab{"genomics"})` fact is already a relationship, with the role string doing the job of a relation name.
+**The cost of an Oso migration is rarely the schema.**
+It's attributes, list endpoints, and the data you'll need to keep in sync.
+
+## Planning
+
+### Find every Oso integration
+
+A codebase can use Oso in more than one service, and in more than one form: an SDK, direct REST calls with no SDK, or a policy file on its own.
+Look for `.polar` files and Oso configuration as well as SDK dependencies, and note which form each integration uses, since it determines what the code stage involves there.
+A codebase may also carry more than one policy, and each contributes to the schema.
+
+Work from the policy that's actually deployed, which you can fetch from Oso, rather than a checked-in copy that may lag behind it.
+
+### What drives the cost
+
+A few questions predict most of the effort:
+
+- **How many unary facts does your policy use?** Facts like `is_public(Document{"readme"})` each become data you keep in sync, and they're typically the largest cost.
+- **Do any rules put a variable in the role position, or compare two stored facts?** These need design decisions, described below.
+- **Do you call `listLocal` or `authorizeLocal`?** If so, you'll need a different approach to database-level filtering.
+- **Do you create Oso clients per request, or read in one service after writing in another?** This affects what consistency you had, and what you'll need in SpiceDB.
+
+### How Polar maps to SpiceDB
+
+| Oso | SpiceDB | Notes |
+| ---------------------------------- | ----------------------------------------- | ---------------------------------------------------------- |
+| `actor` or `resource` block | `definition` | |
+| `roles = [...]` | `relation`, one per role | Roles that are also derived by rule split into two names |
+| `permissions = [...]` | `permission` | Names may need renaming |
+| `relations = {...}` | `relation` to another definition | |
+| `"a" if "b";` | `permission a = b` | |
+| `"a" if "b" on "rel";` | `rel->b` | Both are single-hop |
+| `global` block | A singleton definition and an arrow | Needs a relationship written for every object that uses it |
+| `not` on a fact | `-` exclusion | |
+| Recursive rules | Recursive permissions | |
+| Unary facts | A marker relation, wildcard, or caveat | Needs a decision; see below |
+| Context facts | Caveat context, or stored relationships | Needs a decision; see below |
+| Customer-defined roles | Role objects and subject sets | Maps as data |
+| Variable in the role position | Generated schema | Needs a decision; see below |
+| Predicates with 4 or 5 arguments | An extra definition to hold the arguments | More relationships per fact |
+| Comparison across two stored facts | Caveat context or application code | Needs a decision; see below |
+| Query Builder | Composed lookups in your application | Needs a decision; see below |
+| `listLocal` and `authorizeLocal` | `LookupResources`, or Materialize | Needs a decision; see below |
+
+## Decisions to expect
+
+### Unary facts
+
+A unary fact such as `is_open_access(Dataset{"atlas"})` represents a property of a resource.
+In SpiceDB it becomes a relationship, which means something has to write it when the resource is created, update it when the property changes, and remove it on delete.
+You already sync these facts into Oso, so the migration moves that work rather than adding it.
+
+You can encode each one in a few ways, and the choice affects list performance:
+
+- **A marker relation**, such as `relation archived: system`, with one relationship per flagged object.
+- **A wildcard**, such as `relation public: user:*`, which suits "everyone can" attributes.
+- **A caveat**, when the attribute is really part of the request rather than stored state. This removes the sync work, but the caller has to supply the value on every check.
+
+### Customer-defined roles and runtime permissions
+
+Customer-defined roles mostly map as data: role objects become a definition, and grants become relationships.
+What doesn't map as data is a name invented at runtime, because relation and permission names are part of the SpiceDB schema.
+If your policy has a variable in the role position, or permissions whose names come from users, your options are:
+
+- **Fix the vocabulary.** Oso's own guidance favors a small, fixed set, so many teams can, and it's by far the cheapest option.
+- **Enumerate known roles** and generate a permission for each, which works whenever the set of roles is known when you write the schema.
+- **Generate schema at runtime.** This works, but schema changes then enter your application's write path and apply to every tenant.
+- **Keep the role-to-action mapping in your application,** and have SpiceDB answer only the role question.
+
+### Comparisons between stored facts
+
+Oso's entitlements pattern compares two stored values, such as usage against an allowance.
+A SpiceDB caveat compares stored context against values supplied with the request, so you'll need to move the comparison into your application, precompute the result and keep it in sync, or leave that one check out of the migration for now.
+
+### Query Builder, `listLocal`, and `authorizeLocal`
+
+Oso's Query Builder returns bindings for several variables at once.
+SpiceDB's lookups answer one question at a time, "what can this subject access?" or "who can access this?", so multi-variable queries become joins in your application.
+
+`listLocal` and `authorizeLocal` compile your policy into SQL that your application adds to its own queries, giving you filtering, pagination, and counts in one round trip.
+In SpiceDB, filtering comes from `LookupResources`:
+
+- For small collections, look up the accessible IDs and filter your query with them.
+- For large collections that need sorting and pagination, [AuthZed Materialize](/materialize/getting-started/overview) provides database-native filtering.
+ It doesn't support permissions that use caveats or wildcards, which are common encodings for Oso attributes, so check each permission before relying on it.
+
+### Names and IDs
+
+Oso role and permission names are free-form strings, while SpiceDB names are [identifiers](/spicedb/migrate-to-spicedb/overview#identifiers), so expect to rename some, especially names that came from a UI.
+Watch for names that collide after normalizing, such as `"Admin"` and `"admin"`, or `repo.read` and `repo-read`, because merging two distinct permissions silently grants access.
+Your call sites and tests use the original strings, so keep a record of every rename.
+
+Most object IDs carry over unchanged.
+The usual exceptions are email addresses, IDs containing a `.`, and anything your application percent-encodes.
+
+## Converting the policy
+
+A resource block like this:
+
+```polar
+resource Dataset {
+ permissions = ["download", "annotate"];
+ roles = ["analyst", "curator"];
+ relations = { lab: Lab };
+
+ "download" if "analyst";
+ "annotate" if "curator";
+ "analyst" if "curator";
+ "curator" if "steward" on "lab";
+}
+```
+
+converts to:
+
+```zed
+definition dataset {
+ relation lab: lab
+
+ relation curator__direct: user
+ relation analyst__direct: user
+
+ permission curator = curator__direct + lab->steward
+ permission analyst = analyst__direct + curator
+
+ permission download = analyst
+ permission annotate = curator
+}
+```
+
+An Oso role can be assigned directly with a `has_role` fact and also derived by a rule, so a role that's both becomes a [relation split](/spicedb/migrate-to-spicedb/overview#relation-splits): a `__direct` relation for assignments, and a permission with the original name.
+A role that's only assigned stays a plain relation, and one that's only derived becomes a plain permission.
+
+A few other shapes are easy to miss:
+
+- **Rules written as `allow(...)`.** Free-standing rules can be spelled `has_permission(...)` or `allow(...)`, and some policies use only `allow`. Look for both.
+- **Rules with no body.** `has_permission(_: User, "browse", _: Lab);` grants every user `browse` on every lab. It becomes a wildcard relation plus one relationship per lab, and since it has no `if`, it's easy to overlook.
+- **Self-reference.** A rule like "users can view their own profile" converts with [the `self` keyword](/spicedb/concepts/schema#the-self-keyword), with no relationships needed.
+- **Global blocks.** A `global` block becomes a singleton definition that other definitions reach through an arrow, and every object that references it needs a relationship to the singleton. Nothing in the policy makes that data requirement obvious.
+- **Negation.** Polar's `not` becomes SpiceDB exclusion (`-`). Parenthesize it when mixing operators.
+
+Validate with `zed validate --fail-on-warn`.
+The most common Oso-related warning is an arrow that points at a relation, which needs a permission alias on the target definition.
+
+## Exporting and loading facts
+
+Exporting the policy is simple, but exporting facts has some limits to plan around:
+
+- **You need to know every predicate name in advance,** since there's no endpoint to list them. Get them from the policy.
+- **Fact reads aren't paginated** by the SDKs, so large environments need to split the export.
+- **Some account tiers cap how many facts a call returns,** and return an error rather than truncating.
+- **There's no bulk export or change feed.**
+
+Oso's point-in-time recovery helps a lot here: restoring into a new environment gives you a frozen snapshot to export from at your own pace.
+
+Facts map onto relationships like this:
+
+| Oso fact | SpiceDB relationship |
+| ------------------------------------------------- | ---------------------------------------------------- |
+| `has_role(User:alice, String:steward, Lab:gx)` | `lab:gx#steward__direct@user:alice` |
+| `has_relation(Dataset:atlas, String:lab, Lab:gx)` | `dataset:atlas#lab@lab:gx` |
+| `is_open_access(Dataset:atlas)` | Depends on the encoding you chose for unary facts |
+| A fact with a value, such as an allowance | A relationship with caveat context holding the value |
+
+Role facts are written to the `__direct` relation, while checks use the permission.
+
+### Context facts that fail closed
+
+Oso lets a request carry facts that exist only for that request.
+If those facts carry values, a caveat preserves that shape.
+If they're really edges that your application recomputes on every call, such as a resource hierarchy, they need to be stored as relationships in SpiceDB, which is a new write path.
+
+This matters because the failure is silent.
+Until those relationships are written, every check that depended on them is denied with no error, because the schema and the stored data are both correct.
+Identify every context fact your application passes before you start converting code.
+
+## Updating application code
+
+Oso ships SDKs for several languages, and the method names are the same across them apart from casing: `listLocal` in Node is `list_local` in Python, so search case-insensitively.
+A codebase may also mix SDK generations, which pass facts in different shapes, so read each call site rather than assuming.
+
+| Oso | SpiceDB |
+| ---------------------------------------------- | --------------------------------------- |
+| `authorize` | `CheckPermission` |
+| `authorize_resources`, `actions` | `CheckBulkPermissions` |
+| `list`, `list_paginated` | `LookupResources` |
+| `insert`, `bulk`, `batch` | `WriteRelationships` |
+| `delete` | `DeleteRelationships` |
+| `get` | `ReadRelationships` |
+| `policy` | `WriteSchema` |
+| Query Builder, `list_local`, `authorize_local` | Needs a decision; see above |
+| Context facts | Caveat context, or stored relationships |
+
+Differences worth knowing:
+
+- **`list` and `LookupResources` behave differently.** `LookupResources` streams typed results rather than returning bare IDs, can return duplicates across pages, and returns at most 1,000 results per page. Neither system gives a total count.
+- **Wildcard deletes and reads name the relation in SpiceDB.** An Oso `delete` or `get` that wildcards the middle argument becomes one call per relation.
+- **Batches become one write.** SpiceDB writes are atomic per call, so an Oso `batch` becomes a single `WriteRelationships` call.
+- **Unsolvable rules surface earlier.** A rule that Oso can answer for `authorize` but not for `list` fails at schema-write time in SpiceDB, rather than when a user reaches the endpoint.
+
+### Consistency
+
+Oso doesn't document a consistency guarantee.
+Its SDKs track recent writes per client instance, which gives read-your-writes only within one process, and replica lag can be around a second.
+If your application creates clients per request, or writes in one service and reads in another, it may not have had read-your-writes before.
+
+In SpiceDB, pass the [ZedToken](/spicedb/concepts/consistency#zedtokens) from a write into checks and lookups that depend on it.
+This matters for lookups too, where a stale answer is an empty list rather than one wrong result.
+See [Read-After-Write Consistency](/spicedb/concepts/read-after-write).
+
+## Converting Polar tests
+
+Polar test blocks convert closely into SpiceDB validation files:
+
+```polar
+test "lab staff can download datasets" {
+ setup {
+ has_role(User{"ana"}, "staff", Lab{"genomics"});
+ has_relation(Dataset{"atlas"}, "lab", Lab{"genomics"});
+ }
+
+ assert allow(User{"ana"}, "download", Dataset{"atlas"});
+ assert_not allow(User{"ana"}, "annotate", Dataset{"atlas"});
+}
+```
+
+```yaml
+relationships: |
+ lab:genomics#staff__direct@user:ana
+ dataset:atlas#lab@lab:genomics
+
+assertions:
+ assertTrue:
+ - dataset:atlas#download@user:ana
+ assertFalse:
+ - dataset:atlas#annotate@user:ana
+```
+
+Things to watch:
+
+- **Setup facts use the relation, and assertions use the permission.** Getting this backwards produces a file that passes while testing nothing.
+- **IDs follow the same encoding as your data.** An ID rewritten only in the test file won't match what your application writes.
+- **`iff` assertions make two claims.** `assert allow(..., action, ...) iff action in [...]` means the listed actions are allowed and every other action is denied, so convert both halves.
+- **Fixtures need inlining,** since each validation file is self-contained.
+- **Tests of logic that moved to your application** move with it, so record them rather than dropping them.
+
+## Running both systems side by side
+
+The [side-by-side comparison](/spicedb/migrate-to-spicedb/overview#run-both-systems-side-by-side) on the overview applies as described.
+A few Oso specifics:
+
+- **Oso's local dev server is free,** which makes it cheap to run the Oso side of a comparison or test a converted policy.
+- **Context facts are part of the question.** Replaying an Oso check without the context facts it originally carried asks a different question.
+- **A `list` that errors on an unsolvable rule is an Oso error,** not a disagreement. SpiceDB returning more results in that case is expected.
+- **The comparison harness can see staler Oso answers than your application did,** since it doesn't share your application's client state. Re-ask before treating a single mismatch as real.
+
+## Gotchas
+
+- **Missing relationships fail closed with no error.** Relationships for bodiless rules, `global` blocks, and context facts all produce silent denials if they aren't written.
+- **Look for both `allow` and `has_permission`, and for rules with no body.**
+- **Search SDK calls case-insensitively.** `listLocal` and `list_local` are the same call.
+- **Watch for name collisions** when normalizing role and permission names.
diff --git a/app/spicedb/migrate-to-spicedb/overview/page.mdx b/app/spicedb/migrate-to-spicedb/overview/page.mdx
new file mode 100644
index 00000000..dddbd595
--- /dev/null
+++ b/app/spicedb/migrate-to-spicedb/overview/page.mdx
@@ -0,0 +1,241 @@
+---
+title: "Migrate to SpiceDB"
+description: "What's involved in migrating an existing authorization system to SpiceDB: planning, converting the schema, data, code, and tests, verifying against the source system, and cutting over production traffic."
+---
+
+import { Callout } from "nextra/components";
+
+# Migrate to SpiceDB
+
+Moving an existing authorization system onto SpiceDB comes down to four conversions and a cutover.
+Your authorization model becomes a SpiceDB schema, your existing data becomes relationships, your application's authorization calls become SpiceDB client calls, and your existing tests become SpiceDB validation files.
+Then you confirm the two systems agree, and move production traffic over.
+
+This page describes each stage, the decisions you'll face, and the problems most likely to catch you out, whichever system you're coming from.
+The pages under [Migrate from](#migrate-from) cover what's specific to each source system.
+If you'd like a conceptual comparison first, the pages under [Coming from](/spicedb/getting-started/coming-from/oso) explain how a source system's ideas map onto SpiceDB's.
+
+
+ AuthZed's `spicedb-dev` AI agent plugin can automate much of this work for the source systems it
+ supports, but nothing on this page requires it. See [Automating with the `spicedb-dev`
+ plugin](#automating-with-the-spicedb-dev-plugin).
+
+
+## Stages of a migration
+
+| Stage | What happens | You're done when |
+| ------------------------------------------ | ------------------------------------------------------------- | ----------------------------------------------------------------- |
+| [Plan](#plan-before-you-convert) | Take inventory and make the decisions that affect every stage | Every decision that affects more than one stage is written down |
+| [Schema](#convert-and-validate-the-schema) | Convert the source model to a SpiceDB schema and validate it | `zed validate --fail-on-warn` is clean |
+| [Data](#migrate-the-data) | Extract, transform, load, and verify your relationship data | The loaded data matches the source |
+| [Code](#update-your-application-code) | Replace the source client with a SpiceDB client | Every call site is converted or explicitly flagged |
+| [Tests](#convert-your-tests) | Convert your existing tests to SpiceDB validation files | The converted tests pass |
+| [Verify](#run-both-systems-side-by-side) | Run SpiceDB beside the source system and compare answers | Every permission you plan to cut over has enough agreeing answers |
+| [Cutover](#cutover) | Move production traffic over and retire the source system | The source system is removed |
+
+None of the stages before cutover moves a single production request to SpiceDB.
+They produce a schema, data, code, and evidence that the two systems agree.
+
+A few ordering constraints matter:
+
+- **Make the cross-cutting decisions first.** Tenancy, identifier, and naming choices change the schema, the data, and the code at once, so changing your mind later means redoing work in all three.
+- **Convert the schema before anything else.** The data, code, and tests all use the names the schema defines.
+- **Load and verify the data before running converted code against SpiceDB.** Checks against an empty or partially loaded instance deny everything that touches the missing data.
+- **Data and tests are independent** of each other, so you can do them in either order.
+
+## Plan before you convert
+
+Before converting anything, read your whole authorization model and find every place your application calls the source system.
+The point of this pass is to surface the decisions that affect more than one stage, and to make them together, because they interact: a tenancy choice constrains how you name identifiers, which constrains how you rewrite the data, which constrains every call site.
+
+As you go, note which parts of your model will convert directly and which won't.
+Most models have a core that translates almost mechanically, plus a handful of constructs that need a real design decision, such as a permission computed by a custom query, or roles that customers define at runtime.
+Those few constructs are usually where the effort goes, so identifying them early is the best way to estimate the migration.
+Some constructs are simply more work: if tenants can define new resource types at runtime, for example, each one needs a schema change, which is an ongoing cost rather than a one-time one.
+Others don't belong in the schema at all, such as a comparison between two values your application stores, and stay in application code.
+
+Keep a written record of every decision and mapping you make, and treat it as the single source of truth for the stages that follow.
+Changing a recorded decision after data has been loaded under it isn't a simple refresh, because it changes names that stored data already uses.
+
+### Cross-cutting decisions
+
+| Decision | When it comes up | Options |
+| ----------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Tenancy | The source has more than one isolated store or tenant | One SpiceDB instance with a `tenant` resource type (the usual choice), separate SpiceDB deployments for true isolation, or per-tenant definitions if models genuinely differ. |
+| Identifiers | Any object ID uses characters SpiceDB doesn't allow | Leave legal IDs unchanged, or encode IDs per type. |
+| Relation splits | The source lets one name be both assigned and computed | A consistent naming convention for the stored half, such as a `__direct` suffix. |
+| Permission naming | Permission names read as role nouns instead of verbs | Keep the source names, or rename them. |
+| Consistency | Call sites read right after writing | Map the source's preferences literally, or pass SpiceDB's consistency tokens for read-your-writes. |
+
+#### Identifiers
+
+SpiceDB definition, relation, and permission names are lowercase letters, digits, and underscores, 3 to 64 characters long, with no leading or trailing underscore.
+Object IDs allow letters, digits, and `/ _ | - = +`, up to 1,024 characters, so an `@` in an email address used as an ID is the most common problem.
+
+If some IDs need to change, encoding them per type (for example with base64url) is a safe, reversible default.
+Whatever you choose, the data load and every call site have to encode identically, or checks fail for that type.
+Don't rely only on searching fixtures and config to decide whether your IDs are legal: applications often build IDs at request time, from a token claim, a joined path, or a custom escaping helper, and none of those show up in a search.
+
+#### Relation splits
+
+SpiceDB separates relationships you write (a `relation`) from access it computes (a `permission`).
+Many source systems let one name do both, so converting it means splitting it in two: a relation such as `viewer__direct` that holds direct assignments, and a permission that keeps the original name, `viewer`, and computes access from it.
+
+Getting the two names mixed up fails differently depending on the direction:
+
+- **Writes must target the relation.** A write to a permission returns an error, so this mistake is easy to spot.
+- **Checks must use the permission.** Checking the relation is allowed, and returns only directly assigned subjects, so this mistake produces wrong answers with no error.
+
+Apply the split identically everywhere you touch the data: in the load, in every call site, and in your tests.
+
+#### Sync obligations
+
+Some conversions create a permanent duty to keep SpiceDB consistent with another system, not just a one-time copy.
+This happens whenever your model depends on state that lives elsewhere, such as a resource attribute that becomes a relationship in SpiceDB: something now has to write it on create, update it on change, and remove it on delete.
+How many of these you have is often what separates a straightforward migration from an ongoing synchronization project, so find them during planning.
+For each one, work out where the state comes from, what will keep SpiceDB current, how you'll backfill existing data, and how you'll notice drift.
+
+## Convert and validate the schema
+
+Translate the declarative core of your model first: resource types, relations, and permissions.
+Doing this before touching application code means a mistranslation shows up as a local validation failure instead of a production check that quietly gives the wrong answer.
+
+- Where a construct has more than one valid SpiceDB encoding, weigh the tradeoffs rather than taking the first one that compiles.
+- Source conditions and attribute checks usually become [caveats](/spicedb/concepts/caveats).
+ If one depends on stored state outside SpiceDB, it becomes a relationship you keep in sync instead.
+- Arrows in SpiceDB should point at permissions, so if an arrow's target became a relation during the split, add a permission alias for it.
+- Save renames and restructuring for after the migration.
+ The data, code, and tests all depend on the names you produce now.
+
+Validate with [`zed validate --fail-on-warn`](/spicedb/modeling/validation-testing-debugging#zed-validate).
+Several important problems are only warnings, such as an arrow that points at a relation, so a plain `zed validate` can pass while the schema is wrong.
+Validation checks the schema against its own assertions, not against your data.
+
+See the [schema language reference](/spicedb/concepts/schema) and [Developing a Schema](/spicedb/modeling/developing-a-schema) for SpiceDB's side of each construct.
+
+## Migrate the data
+
+Data migration is usually a script that extracts your source data, transforms it using the mappings you recorded, and loads it into SpiceDB.
+It writes to a live authorization system, so a mistake is expensive to undo.
+
+Things to plan for:
+
+- **Deploy the schema first.** A load against a missing or stale schema fails in confusing ways.
+- **Make sure the extraction is complete.** Export tools and APIs often page or cap results silently, so count the source independently rather than trusting the export.
+- **Transform using the model, not just the data.** Many sources store data whose types are only implicit, so deciding which relation a record belongs to, and whether its IDs need encoding, requires the model.
+- **Use idempotent writes.** Writing with `TOUCH` rather than `CREATE` means a re-run or resumed load doesn't fail on relationships that already exist.
+ For large volumes, [bulk import](/spicedb/ops/data/bulk-operations) is faster but fails on existing relationships, so pair it with a `TOUCH` fallback.
+ See [Writing relationships](/spicedb/ops/data/writing-relationships).
+- **Checkpoint long loads** so an interruption doesn't mean starting over.
+
+### Verify the load
+
+Verification should read SpiceDB back, not just confirm that you read the source correctly:
+
+- **Compare an independent source count** with what you extracted, to catch an export that under-read the source.
+- **Read the relationships back from SpiceDB** and confirm the total matches, with nothing missing or duplicated.
+- **Spot-check permissions in both directions.** Check a sample of access you expect to be allowed, and pair each with access you know is denied.
+ A transform bug that drops a condition turns a conditional grant into an unconditional one, and only a check you expect to fail can reveal it.
+
+Note that `zed validate` only evaluates the relationships in its own file, so it can't tell you anything about the data you loaded.
+
+### Keep the data in sync
+
+A one-time copy is out of date as soon as extraction ends if the source is still taking writes.
+Either pause writes to the source during the load, or replay changes made since extraction until the two agree.
+A matching count on a live store is necessary but not sufficient, since a delete and a write in the same window cancel out.
+For SpiceDB's own change feed, see [Watching changes](/spicedb/concepts/watch).
+
+## Update your application code
+
+This stage adds a SpiceDB [client library](/spicedb/getting-started/client-libraries) and rewrites every call site of the source client.
+It edits code your team owns and changes dependencies, so review it like any large refactor.
+
+These problems compile cleanly and then fail or answer incorrectly at runtime:
+
+- **Split names.** Use the relation for writes and relationship filters, and the permission for checks and lookups.
+- **Unencoded IDs.** If you encoded IDs during the load, every call site that builds an ID of that type has to encode it the same way.
+- **Result ordering.** Some sources return batch results keyed by a correlation ID, while SpiceDB returns them in request order.
+- **Async clients.** If your source client was synchronous and the SpiceDB client for your language isn't, a missed `await` can turn a denial into an allow. In Python, for example, an un-awaited coroutine is truthy.
+- **Read-after-write.** A check that runs right after a related write can see the previous state unless you pass the write's [ZedToken](/spicedb/concepts/consistency#zedtokens).
+ See [Read-After-Write Consistency](/spicedb/concepts/read-after-write).
+
+### Don't turn an unconverted check into a denial
+
+If you can't convert a call site yet, avoid making it return `false`.
+A `false` that means "not implemented" looks exactly like a real denial, and the gap later resurfaces as a mysterious permission failure.
+Where a caller depends on the result, raise an error instead, and mark every unconverted or approximated call site with a searchable comment so nothing is forgotten.
+
+A clean build only shows the rewrite compiles.
+Whether it behaves like the source system is what [running both systems side by side](#run-both-systems-side-by-side) is for.
+
+## Convert your tests
+
+Your source system's test suite is an oracle you already have.
+Convert its fixtures and assertions into [SpiceDB validation files](/spicedb/modeling/validation-testing-debugging#zed-validate).
+
+- **Validation files have one shared set of relationships.** If your source tests each carry their own data, you'll need to merge them, or split tests whose data genuinely conflicts into separate files.
+- **"Who can access this?" and "what can this user access?" assertions don't convert.** Validation files only express individual checks, so verify list-style behavior against a running instance with `LookupResources` and `LookupSubjects`.
+- **Passing tests prove less than they seem.** They confirm the assertions you kept, not all of the source system's behavior.
+
+Keep track of anything you couldn't convert, so you know which guarantees still need covering.
+
+## Run both systems side by side
+
+Converted tests only cover cases someone wrote down.
+Running SpiceDB beside the source system on real traffic covers everything else: each real decision the source makes is also sent to SpiceDB in the background, and the answers are compared without affecting what the caller gets.
+
+What makes this comparison trustworthy:
+
+- **It can never affect a real decision.** SpiceDB's answer is logged and compared, never returned to a caller. A slow or failed SpiceDB call should cost a missing record, never a failed request.
+- **It distinguishes errors from denials.** Record whether each answer was allowed, denied, conditional on missing context, errored, or never arrived. Once an error is stored as a denial, the two can't be told apart.
+- **It accounts for timing.** A check made just after a write can briefly return the previous answer. Before treating a mismatch as real, re-ask SpiceDB at a consistency level at least as fresh as the answer it gave.
+- **It covers the permission surface, not just the traffic.** A rarely used permission, or one resolved through a parent relationship, needs much more sampling than a busy one. Confirm every resource type and permission you plan to cut over has a meaningful number of distinct comparisons, not just a high agreement rate over a few repeated questions.
+- **It handles the data it stores carefully.** Comparison records contain real resource and user IDs, and possibly request context such as IP or email addresses. Decide retention and what to store up front.
+
+Over time, confirmed agreements can become permanent regression tests.
+
+
+ A passing comparison only measures the questions production happened to ask. A permission can look
+ fully verified while a different way of reaching it, such as through a parent, a wildcard, or a
+ condition, was never exercised.
+
+
+## Cutover
+
+Moving production traffic from the source system to SpiceDB is a separate, later step, and the one migrations most often get wrong.
+Rather than switching everything at once, expand SpiceDB's responsibility one resource type at a time:
+
+1. **Start with one representative resource type.** Pick one that's low-risk but has a real parent-child relationship, since hierarchy is where SpiceDB's behavior differs most from a flat role check. Run the data, code, and test stages on it before widening.
+2. **Write to both systems and compare reads.** Writes still go to the source first and are mirrored to SpiceDB. The source still answers every check, and SpiceDB answers the same question in the background.
+3. **Reconcile continuously.** Some drift between the two systems is normal, so run an ongoing job that checks for it, rather than trusting that dual writes never fail.
+4. **Switch one resource type at a time behind a flag.** Make sure the flag can be flipped back without a deploy, and keep the source path working until the new setting has proven itself.
+5. **Remove the source system last,** only after reconciliation has stayed quiet for a full cycle of how your product is used, such as a billing cycle. Make sure "quiet" means the systems agreed, not that comparisons stopped happening.
+
+These last steps depend on your deployment and risk tolerance, and only your team can decide when giving up the fallback path is acceptable.
+
+## Automating with the `spicedb-dev` plugin
+
+AuthZed's `spicedb-dev` AI agent plugin automates much of the work above for OpenFGA, Okta FGA, Auth0 FGA, and Oso Cloud.
+If it detects a source it doesn't support, it says so rather than improvising a translation.
+To install and run it, see [Build with your AI agent](/spicedb/getting-started/build-with-an-agent#migrating-to-spicedb).
+
+| Stage | Plugin command | What it produces |
+| ------ | ----------------------------- | ----------------------------------------------------------- |
+| Plan | `/spicedb-dev:migrate` | A migration plan and a machine-readable record of decisions |
+| Schema | `/spicedb-dev:migrate-schema` | A `.zed` schema and a validation report |
+| Data | `/spicedb-dev:migrate-data` | A migration script and identifier encoder in your language |
+| Code | `/spicedb-dev:migrate-code` | A SpiceDB client and rewritten call sites |
+| Tests | `/spicedb-dev:migrate-tests` | SpiceDB validation files |
+| Verify | `/spicedb-dev:migrate-verify` | A comparison harness in your repository, for you to wire in |
+
+Cutover has no command, because it depends on your deployment.
+
+## Migrate from
+
+These pages cover what's specific to each source system: how its constructs map onto SpiceDB, the decisions to expect, and the details of moving its data, code, and tests.
+
+- [OpenFGA and Okta FGA](/spicedb/migrate-to-spicedb/migrate-from/openfga)
+- [Oso](/spicedb/migrate-to-spicedb/migrate-from/oso)
+
+For a conceptual comparison, see the pages under [Coming from](/spicedb/getting-started/coming-from/oso), which also cover [Open Policy Agent](/spicedb/getting-started/coming-from/opa) and [Ruby on Rails](/spicedb/getting-started/coming-from/cancancan).