diff --git a/.gitignore b/.gitignore
index ebae1a3d..693c8837 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,6 @@ public/robots.txt
public/sitemap-0.xml
public/sitemap.xml
public/feed.json
+
+# macOS
+.DS_Store
diff --git a/app/globals.css b/app/globals.css
index b1c19af9..a130fd32 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -2,6 +2,54 @@
@custom-variant dark (&:is(html[class~="dark"] *));
+/* ===========================================================================
+ Sandworm accent tokens — site-wide.
+ Raw HSL triplets (consumed as `hsl(var(--teal-500) / 0.5)`), so any
+ component can tint without redeclaring a color. LIGHT is the base; DARK
+ overrides under `html.dark` (next-themes, which also resolves "system").
+ The light shades are deliberately DARKER than Sandworm's native register
+ so they stay legible on a light ground — don't "correct" them upward
+ without re-checking contrast.
+ Previously scoped under `.docs-home` in components/docs-home.css, which
+ put them out of reach of everything else on the site. The `--stone-*` ramp
+ stays scoped there: it's inverted for that page and isn't general-purpose.
+ ======================================================================== */
+:root {
+ --magenta-300: 316 50% 45%;
+ --magenta-400: 316 48% 47%;
+ --magenta-500: 316 50% 44%;
+ --magenta-600: 316 52% 40%;
+ --magenta-950: 315 33.3% 7.1%;
+ --red-400: 352 70% 48%;
+ --teal-300: 178 40% 32%;
+ --teal-400: 178 38% 35%;
+ --teal-500: 178 35% 33%;
+ --teal-700: 178 30% 26%;
+ --violet-400: 253 60% 52%;
+ --violet-500: 253 62% 50%;
+ --violet-700: 255 50% 46%;
+ --sand-300: 30 75% 42%;
+ --blue-500: 200 75% 40%;
+}
+
+html.dark {
+ --magenta-300: 318 41.2% 70%;
+ --magenta-400: 317 40.2% 60%;
+ --magenta-500: 316 39.2% 51%;
+ --magenta-600: 316 42% 45%;
+ --magenta-950: 315 33.3% 7.1%;
+ --red-400: 355 91% 73.9%;
+ --teal-300: 175 27.5% 72.9%;
+ --teal-400: 176 28% 56%;
+ --teal-500: 177 28.2% 45.9%;
+ --teal-700: 178 30% 30%;
+ --violet-400: 253 76.5% 70%;
+ --violet-500: 253 73.4% 63.1%;
+ --violet-700: 255 58% 43.9%;
+ --sand-300: 28 100% 72%;
+ --blue-500: 195 73.4% 63.1%;
+}
+
@theme {
--breakpoint-*: initial;
--breakpoint-sm: 640px;
diff --git a/app/materialize/api/_meta.ts b/app/materialize/api/_meta.ts
index 065e4263..c4e451f6 100644
--- a/app/materialize/api/_meta.ts
+++ b/app/materialize/api/_meta.ts
@@ -2,4 +2,5 @@ export default {
"client-sdks": "Client SDKs",
"watch-permission-sets": "WatchPermissionSets",
"lookup-permission-sets": "LookupPermissionSets",
+ "download-permission-sets": "DownloadPermissionSets",
};
diff --git a/app/materialize/api/client-sdks/page.mdx b/app/materialize/api/client-sdks/page.mdx
index 0af9a011..5f591be5 100644
--- a/app/materialize/api/client-sdks/page.mdx
+++ b/app/materialize/api/client-sdks/page.mdx
@@ -5,6 +5,8 @@ description: "SDK versions with generated gRPC and protobuf support for the Auth
# Client SDKs
+
+
All SpiceDB SDKs have the generated gRPC and protobuf code
- [authzed-go v0.15.0](https://github.com/authzed/authzed-go/releases/tag/v0.15.0)
diff --git a/app/materialize/api/download-permission-sets/page.mdx b/app/materialize/api/download-permission-sets/page.mdx
new file mode 100644
index 00000000..212ef933
--- /dev/null
+++ b/app/materialize/api/download-permission-sets/page.mdx
@@ -0,0 +1,103 @@
+---
+title: "DownloadPermissionSets"
+description: "Bulk-download permission sets as Parquet or Avro files for a fast initial backfill, with guidance on parsing normalized and denormalized files and why Parquet is the recommended format."
+---
+
+import { Callout } from "nextra/components";
+
+# DownloadPermissionSets
+
+
+
+For large datasets, `DownloadPermissionSets` is a faster alternative to [LookupPermissionSets] for the initial [backfill](../concepts/permission-set-lifecycle): instead of streaming individual events over a single connection, it hands you a manifest of files you can fetch directly from blob storage, in parallel.
+
+## Request
+
+The request maps to [`DownloadPermissionSetsRequest`]:
+
+```json
+{
+ "optional_at_revision": "the_zed_token"
+}
+```
+
+If `optional_at_revision` is omitted, Materialize returns files for the latest fully-published revision. If provided, Materialize returns files for that specific revision if they're still available (see [Snapshot rotation](../concepts/snapshots#snapshot-lifecycle-events)).
+
+Once you've backfilled from the downloaded files, switch to [WatchPermissionSets] to keep your copy current. See [The Permission Set Lifecycle](../concepts/permission-set-lifecycle) for the full flow.
+
+## Response
+
+The response ([`DownloadPermissionSetsResponse`]) lists the available [`File`]s:
+
+```json
+{
+ "timestamp": "2026-07-01T12:00:00Z",
+ "files": [
+ { "name": "metadata.json", "url": "https://..." },
+ { "name": "members.parquet", "url": "https://..." },
+ { "name": "sets.parquet", "url": "https://..." },
+ { "name": "member_to_set.parquet", "url": "https://..." },
+ { "name": "set_to_set.parquet", "url": "https://..." }
+ ]
+}
+```
+
+Each file's `url` is a **time-limited signed URL**: download promptly, and re-call `DownloadPermissionSets` to get fresh URLs if a batch download runs long. `metadata.json` is always included and describes the revision:
+
+```json
+{
+ "revision": "the_zed_token",
+ "watched_permissions": ["document#view@user", "document#edit@user"],
+ "numShards": 4,
+ "files": ["members-shard-0.parquet", "members-shard-1.parquet", "..."]
+}
+```
+
+
+ Large datasets are sharded across multiple files per table (`members-shard-0.parquet`,
+ `members-shard-1.parquet`, and so on). The internal IDs in each table are only unique *within
+ their own shard*. Read `metadata.json`'s `files` list to discover the actual shard files for a
+ revision, and treat each table's shards as one combined multi-file dataset rather than
+ concatenating them naively.
+
+
+## File formats
+
+
+ Avro support is being phased out. New integrations should use Parquet: it's faster to query, works
+ natively with standard data tools, and is where future development is focused.
+
+
+Which formats a given revision publishes depends on how your Materialize instance is configured. Work with your AuthZed account team to enable Parquet if you're not seeing it in your manifest yet.
+
+### Parquet (recommended)
+
+Parquet ships in two shapes, covering the same [permission set](../concepts/permission-sets) data at different levels of normalization:
+
+- **Normalized** (`members.parquet`, `sets.parquet`, `member_to_set.parquet`, `set_to_set.parquet`): mirrors the underlying model directly. `members.parquet` and `sets.parquet` assign a compact integer `internal_id` to each subject and each resource#permission set. `member_to_set.parquet` expresses each **member → set** edge as a `member_id` column plus a `sets` column (a list of set IDs). `set_to_set.parquet` expresses each **set → set** edge (see [Permission Sets](../concepts/permission-sets) for what "child" and "parent" mean here) as a `child_set_id` column plus a `parent_sets` column (a list of parent set IDs). Smaller on disk, but you'll need to join back to `members`/`sets` to recover the actual `subject_type`/`subject_id` or `resource_type`/`resource_id` values.
+- **Flat** (`flat_member_to_set.parquet`, `flat_set_to_set.parquet`): fully denormalized, every row already carries the resolved string values directly, no lookup tables, no joins. `flat_member_to_set.parquet` has `resource_type`, `resource_rel`, `resource_id`, `subject_type`, `subject_rel`, `subject_id`. `flat_set_to_set.parquet` has the same six fields split into `child_resource_type`/`child_resource_rel`/`child_resource_id` and `parent_resource_type`/`parent_resource_rel`/`parent_resource_id`. Pick this if you're loading straight into a query engine or your own database.
+
+Both shapes are plain Parquet files, so any Parquet reader works: DuckDB, pandas/pyarrow, Spark, and most data warehouses can query them directly without a custom parser. For example, with the flat files and [DuckDB](https://duckdb.org/):
+
+```sql
+-- Every subject that can view a given document, no joins required
+SELECT subject_type, subject_id
+FROM 'flat_member_to_set.parquet'
+WHERE resource_type = 'document' AND resource_id = '123' AND resource_rel = 'view';
+```
+
+### Avro (legacy)
+
+Avro files (`members.avro`, `sets.avro`, `member_to_set.avro`, `set_to_set.avro`) use the same normalized shape as the Parquet equivalents: integer `internal_id`s in `members`/`sets`, referenced by `member_to_set`/`set_to_set`. They're encoded as Avro [Object Container Format](https://avro.apache.org/docs/current/specification/#object-container-files), which embeds its own schema, so any standard Avro library can decode a file without needing the schema separately.
+
+## Errors
+
+### NotFound
+
+Returned when no download manifest exists for the requested revision (or for the latest revision, if none was specified). For example, if hydration hasn't published a manifest yet, or the requested revision has aged out. Retry against the latest revision, or re-run without `optional_at_revision`.
+
+[WatchPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.WatchPermissionSets
+[LookupPermissionSets]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.WatchPermissionSetsService.LookupPermissionSets
+[`DownloadPermissionSetsRequest`]: https://buf.build/authzed/api/file/main%3Aauthzed/api/materialize/v0/
+[`DownloadPermissionSetsResponse`]: https://buf.build/authzed/api/file/main%3Aauthzed/api/materialize/v0/
+[`File`]: https://buf.build/authzed/api/file/main%3Aauthzed/api/materialize/v0/
diff --git a/app/materialize/api/lookup-permission-sets/page.mdx b/app/materialize/api/lookup-permission-sets/page.mdx
index 59d4506b..aab23e5c 100644
--- a/app/materialize/api/lookup-permission-sets/page.mdx
+++ b/app/materialize/api/lookup-permission-sets/page.mdx
@@ -7,6 +7,8 @@ import { Callout } from "nextra/components";
# LookupPermissionSets
+
+
This API complements [WatchPermissionSets].
When you first bring on a system that needs permissions data, [LookupPermissionSets] lets you create an initial snapshot of the permissions data, and then you can use the [WatchPermissionSets] API to keep the snapshot updated.
diff --git a/app/materialize/api/watch-permission-sets/page.mdx b/app/materialize/api/watch-permission-sets/page.mdx
index e8cd739e..3542fa39 100644
--- a/app/materialize/api/watch-permission-sets/page.mdx
+++ b/app/materialize/api/watch-permission-sets/page.mdx
@@ -7,6 +7,8 @@ import { Callout } from "nextra/components";
# WatchPermissionSets
+
+
This is an update stream of all the permissions Materialize is configured to watch.
You can use this to store all permissions tracked in the system closer to your application database to be used in database-native ACL filtering.
Permissions can also be stored in secondary indexes like Elasticsearch.
diff --git a/app/materialize/concepts/_meta.ts b/app/materialize/concepts/_meta.ts
index eeb3adaa..e5c33ea9 100644
--- a/app/materialize/concepts/_meta.ts
+++ b/app/materialize/concepts/_meta.ts
@@ -1,6 +1,8 @@
export default {
+ "watched-permissions": "Watched Permissions",
+ snapshots: "Snapshots",
+ hydration: "Hydration",
"permission-sets": "Permission Sets",
"permission-set-lifecycle": "Permission Set Lifecycle",
- snapshots: "Snapshots",
"managing-client-state": "Managing Client State",
};
diff --git a/app/materialize/concepts/hydration/page.mdx b/app/materialize/concepts/hydration/page.mdx
new file mode 100644
index 00000000..d570fb57
--- /dev/null
+++ b/app/materialize/concepts/hydration/page.mdx
@@ -0,0 +1,26 @@
+---
+title: "Hydration"
+description: "Hydration is how Materialize turns your SpiceDB schema and relationships into precomputed permission sets, computing only what's reachable from the permissions you've configured it to watch."
+---
+
+# Hydration
+
+
+
+**Hydration** is the process Materialize uses to turn your SpiceDB schema and relationship data into the precomputed [permission sets](./permission-sets) that both Materialize features depend on: [Accelerated Queries](../getting-started/overview#accelerated-queries) reads hydrated data directly to answer checks and lookups, and [Event Streams](../getting-started/overview#event-streams) exposes that same hydrated data through `LookupPermissionSets` and `WatchPermissionSets`.
+
+## Only what's reachable
+
+Hydration doesn't compute every permission your schema could produce, only the [permissions you've configured Materialize to watch](./watched-permissions), plus everything those permissions structurally depend on through unions, intersections, exclusions, and arrows. This **reachability** is a pure function of your schema and your watched permissions, so Materialize computes it once per hydration run and reuses it throughout.
+
+This is also why the cost of hydration tracks the size of the reachable graph, not the size of your whole schema: watching a permission with a much wider reach, or adding a new watched permission, can noticeably change how much work hydration has to do.
+
+## Offline hydration vs. online hydration
+
+- **Offline hydration** reads a full [snapshot](./snapshots) of your relationship data and computes every reachable permission set from scratch. This is what backs the initial [backfill](./permission-set-lifecycle).
+- **Online hydration** applies as new relationship and schema changes arrive after that initial pass, updating only the permission sets a given change actually affects, in the order the changes occurred.
+
+## When rehydration is required
+
+A [breaking schema change](./snapshots#snapshot-lifecycle-events) invalidates previously hydrated permission sets, since the reachability computed from the old schema no longer holds.
+Materialize signals this so you can re-run the [backfill](./permission-set-lifecycle) at the new revision. See [The Permission Set Lifecycle](./permission-set-lifecycle) for the full rebuild flow.
diff --git a/app/materialize/concepts/managing-client-state/page.mdx b/app/materialize/concepts/managing-client-state/page.mdx
index 0f6764c2..41a7d41f 100644
--- a/app/materialize/concepts/managing-client-state/page.mdx
+++ b/app/materialize/concepts/managing-client-state/page.mdx
@@ -5,6 +5,8 @@ description: "State diagram of the transitions a client application moves throug
# Managing Client State
+
+
This diagram shows the various states your client application will need to transition through when calling the [LookupPermissionSets] and the [WatchPermissionSets] APIs.

diff --git a/app/materialize/concepts/permission-set-lifecycle/page.mdx b/app/materialize/concepts/permission-set-lifecycle/page.mdx
index 7d8b1407..34f4c4cc 100644
--- a/app/materialize/concepts/permission-set-lifecycle/page.mdx
+++ b/app/materialize/concepts/permission-set-lifecycle/page.mdx
@@ -7,6 +7,8 @@ import { Callout } from "nextra/components";
# The permission set lifecycle
+
+
The **permission set lifecycle** describes how your application's local copy of permission data is populated, kept current, and rebuilt over time — from an empty index to a complete, queryable [Permission Sets](./permission-sets) store, and back to a fresh index when your schema changes.
When you first bring a system online, you have no permission data locally.
@@ -16,7 +18,7 @@ Once the backfill is complete, you switch to [WatchPermissionSets](../api/watch-
## Stages of the lifecycle
1. **Backfill** — populate your store with the current state of every permission set. Two APIs can produce the initial backfill:
- - **`DownloadPermissionSets`** (the Download API) — **recommended**. It ingests significantly faster than `LookupPermissionSets` and is the preferred path for the initial backfill.
+ - **[DownloadPermissionSets](../api/download-permission-sets)** (the Download API) — **recommended**. It ingests significantly faster than `LookupPermissionSets` and is the preferred path for the initial backfill.
- **[LookupPermissionSets](../api/lookup-permission-sets)** — kept available for clients that find it easier to integrate with. Page through the stream via cursors until an iteration yields zero events. Each event carries the permission-set data to store **and** the cursor to resume from on failure.
2. **Record the snapshot revision** — every backfill event includes the revision (`ZedToken`) the data was computed at. Store it transactionally alongside the data.
3. **Switch to the change stream** — once the backfill completes, open [WatchPermissionSets](../api/watch-permission-sets) using the stored snapshot revision so no changes are missed between the backfill and live updates.
diff --git a/app/materialize/concepts/permission-sets/page.mdx b/app/materialize/concepts/permission-sets/page.mdx
index b7b0f88b..211a59c4 100644
--- a/app/materialize/concepts/permission-sets/page.mdx
+++ b/app/materialize/concepts/permission-sets/page.mdx
@@ -7,13 +7,57 @@ import { Callout } from "nextra/components";
# Permission Sets
+
+
A **permission set** is the unit of precomputed authorization data that Materialize produces.
-Where SpiceDB answers a permission question on demand by walking the relationship graph, Materialize continuously **precomputes** the membership of the permissions you configure it to watch and exposes that denormalized data to your application.
+Where SpiceDB answers a permission question on demand by walking the relationship graph, Materialize continuously [**hydrates**](./hydration) the membership of the [permissions you configure it to watch](./watched-permissions) and exposes that denormalized data to your application.
Conceptually, a permission set captures two kinds of edges:
- **Member → set** — a subject (e.g. `user:evan`) is a member of a permission set (e.g. `document:123#view`).
-- **Set → set** — one set is nested inside another (e.g. `group:shared#member` grants `document:456#view`).
+- **Set → set** — one set is nested inside another. The nested set can be a plain relation, like a group membership (`group:engineering#member` grants `document:456#view`), or another permission entirely, like an edit permission that also grants view access (`document:123#edit` grants `document:123#view`).
+
+
+See the schema and relationships that produce the edges above
+
+```zed
+definition user {}
+
+definition group {
+ relation member: user
+}
+
+definition document {
+ relation viewer: user | group#member
+ relation editor: user
+
+ permission edit = editor
+ permission view = viewer + edit
+}
+```
+
+```zed
+document:123#viewer@user:evan
+document:123#editor@user:alex
+document:456#viewer@group:engineering#member
+```
+
+- `document:123#viewer@user:evan` makes `user:evan` a **member** of the `document:123#view` permission set.
+- `document:123#editor@user:alex` makes `user:alex` a member of `document:123#edit`. Because `view` is defined as `viewer + edit`, `document:123#edit` is nested in `document:123#view` — the **set → set** edge from the permission example above.
+- `document:456#viewer@group:engineering#member` makes the `group:engineering#member` relation itself a viewer of `document:456`, nesting `group:engineering#member` inside `document:456#view` — the **set → set** edge from the group example above.
+
+
+
+
+ Materialize (and the files it produces, see
+ [DownloadPermissionSets](../api/download-permission-sets#file-formats)) calls the nested set the
+ **child** and the set it grants into the **parent** — but this isn't a containment hierarchy like
+ a filesystem or an org chart, where a parent owns and cascades down to its children. It's closer
+ to set membership: being in the child set implies you're also in the parent set, so the grant
+ flows from child up to parent, not parent down to child. It's also not a strict tree — a single
+ child set can have many parents at once (e.g., one group's membership might grant view access to
+ many different documents), so don't assume a child has only one parent.
+
Together these let your application reconstruct "which resources can this subject access" — and the inverse — without issuing a `LookupResources` or `CheckPermission` call per request.
This is what makes authorization-aware search, sorting, and filtering over large result sets practical.
diff --git a/app/materialize/concepts/snapshots/page.mdx b/app/materialize/concepts/snapshots/page.mdx
index 255a5140..bc13d7cd 100644
--- a/app/materialize/concepts/snapshots/page.mdx
+++ b/app/materialize/concepts/snapshots/page.mdx
@@ -7,11 +7,14 @@ import { Callout } from "nextra/components";
# Snapshots
+
+
A **snapshot** is a point-in-time, internally consistent view of every [Permission Set](./permission-sets) Materialize is tracking, computed at a specific SpiceDB revision.
Every piece of permission data Materialize hands you is anchored to the revision (`ZedToken`) it was computed at — that revision _is_ the snapshot's identity.
-Snapshots are what make the two Materialize APIs compose safely:
+Snapshots underpin both Materialize features: they're what Materialize [hydrates](./hydration) in order to answer Accelerated Queries, and they're what make the Event Streams APIs compose safely:
+- Materialize reads the current snapshot directly to answer Accelerated Queries like `CheckPermission`, `CheckBulkPermissions`, `LookupResources`, and `LookupSubjects`.
- [The initial backfill](./permission-set-lifecycle) reads a snapshot in full.
- [WatchPermissionSets](../api/watch-permission-sets) advances that snapshot forward, delivering the deltas that move it from one revision to the next.
diff --git a/app/materialize/concepts/watched-permissions/page.mdx b/app/materialize/concepts/watched-permissions/page.mdx
new file mode 100644
index 00000000..b22ca832
--- /dev/null
+++ b/app/materialize/concepts/watched-permissions/page.mdx
@@ -0,0 +1,53 @@
+---
+title: "Watched Permissions"
+description: "A watched permission is a resource#permission@subject tuple that tells Materialize which permission to precompute — the configuration that drives hydration and everything built on top of it."
+---
+
+import { Callout } from "nextra/components";
+
+# Watched Permissions
+
+
+
+A **watched permission** is the unit of configuration for Materialize: a `resource#permission@subject` tuple that tells Materialize which permission, for which subject type, to precompute.
+Everything Materialize does starts from the list of watched permissions you configure.
+
+## Format
+
+A watched permission is written the same way you'd write a relationship or a check, minus the specific object IDs:
+
+```zed
+resource#view@user
+resource#edit@user
+```
+
+Each tuple names:
+
+- The **resource type** holding the permission (`resource`)
+- The **permission or relation** to watch (`view`, `edit`)
+- The **subject type** Materialize should compute membership for (`user`)
+
+If you only care about a specific relation on the subject (for example, `group#member` rather than the group itself) you can specify that too: `resource#view@group#member`.
+
+## What watching a permission actually does
+
+Materialize doesn't precompute your whole schema. It only tracks your watched permissions and whatever they structurally reach through unions, intersections, exclusions, and arrows. That reachable set is exactly what [hydration](./hydration) computes and keeps up to date.
+
+Because reachability is a pure function of your schema and your watched permissions, adding a new watched permission, or watching a permission with a much wider reach through your schema, extends that graph, and with it, the amount of work hydration has to do.
+
+## Constraints
+
+A watched permission must reference real schema elements: an existing resource type, an existing permission or relation on it, and an existing subject type. Materialize validates this when you configure it and will reject a permission that doesn't resolve.
+
+The permission's path also can't cross any of the schema features Materialize doesn't support. See [Limitations](../getting-started/limitations) for the full list.
+
+## Configuring watched permissions
+
+
+ During early access, work with your AuthZed account team to configure your list of watched
+ permissions.
+
+
+[Caveats]: https://authzed.com/docs/spicedb/concepts/caveats
+[Wildcard]: https://authzed.com/docs/spicedb/concepts/schema#wildcards
+[.all intersections]: https://authzed.com/docs/spicedb/concepts/schema#all-intersection-arrow
diff --git a/app/materialize/getting-started/limitations/page.mdx b/app/materialize/getting-started/limitations/page.mdx
index 3d2c5780..e903e35e 100644
--- a/app/materialize/getting-started/limitations/page.mdx
+++ b/app/materialize/getting-started/limitations/page.mdx
@@ -5,6 +5,8 @@ description: "Current limitations of AuthZed Materialize, including caveats, wil
# Limitations
+
+
- Your schema can contain any of the following, but they cannot be on the path of your configured Materialize permissions or it will throw an error:
- [Caveats]
- [Wildcard] subject types
diff --git a/app/materialize/getting-started/overview/page.mdx b/app/materialize/getting-started/overview/page.mdx
index f8ff5cd1..ca783e3f 100644
--- a/app/materialize/getting-started/overview/page.mdx
+++ b/app/materialize/getting-started/overview/page.mdx
@@ -4,24 +4,45 @@ description: "AuthZed Materialize precomputes the permissions you configure it t
---
import { Callout } from "nextra/components";
+import MaterializeArchitecture from "@/public/images/materialize-architecture.svg";
# What is Materialize?
+AuthZed Materialize takes inspiration from the Leopard index component described in the [Zanzibar paper](https://zanzibar.tech/2IoYDUFMAE:0:T).
+Much like the concept of a materialized view in relational databases, AuthZed Materialize is a service that you configure with a list of permissions that you want it to precompute, and it will calculate how those permissions change after relationships
+are written (specifically, when those relationships affect a subject's membership in a permission set or a set's permission on a specific resource), or when a new schema is written.
+
+Materialize puts that precomputed data to work in two ways: **Accelerated Queries** and **Event Streams**, both currently in early access. Every other piece of Materialize content belongs to one of these two features. Look for badges at the top of the page, which link back to the feature it belongs to.
+
+
+
+## Accelerated Queries
+
- AuthZed Materialize is available to users of AuthZed [Dedicated] as part of an early access
+ Accelerated Queries is available to users of AuthZed [Dedicated] as part of an early access
program. Don't hesitate to get in touch with your AuthZed account team if you would like to
participate.
-AuthZed Materialize takes inspiration from the Leopard index component described in the [Zanzibar paper](https://zanzibar.tech/2IoYDUFMAE:0:T).
-Much like the concept of a materialized view in relational databases, AuthZed Materialize is a service that you configure with a list of permissions that you want it to precompute, and it will calculate how those permissions change after relationships
-are written (specifically, when those relationships affect a subject's membership in a permission set or a set's permission on a specific resource), or when a new schema is written.
-These precomputed permissions can then be used either to provide faster checks and lookups through Accelerated Queries, or streamed into your own application database and **colocated** alongside the data they protect — a pattern known as **colocated permissions** — so you can search, sort, filter, and paginate over authorized objects natively and much more efficiently.
-
-In summary, AuthZed Materialize allows you to:
+Accelerated Queries use Materialize's precomputed permissions cache to answer the SpiceDB queries your application already makes, without changing how you call SpiceDB:
- Speed up `CheckPermission` and `CheckBulkPermissions`.
- Speed up `LookupResources` and `LookupSubjects`, especially when there is a large number of resources.
+
+## Event Streams
+
+
+ Event Streams is available to users of AuthZed [Dedicated] as part of an early access program.
+ Don't hesitate to get in touch with your AuthZed account team if you would like to participate.
+
+
+Event Streams let you take Materialize's precomputed permission data out of SpiceDB and keep your own copy of it — either colocated alongside the data it protects in your application database, or in a secondary index like a search engine.
+
+- Download the current state of every [permission set](../../concepts/permission-sets) with `LookupPermissionSets`, then keep it current in real time with `WatchPermissionSets`. See [The Permission Set Lifecycle](../../concepts/permission-set-lifecycle).
- Build authorization-aware UIs — sort, filter, and paginate over many thousands of authorized objects natively in your own database by colocating computed permissions next to your application data.
- Perform ACL filtering in secondary indexes too, like a search index (e.g. Elasticsearch) — authorization-aware UIs and search-index filtering are the same idea (ACL filtering) applied to different stores.
diff --git a/app/materialize/guides/recommended-architecture/page.mdx b/app/materialize/guides/recommended-architecture/page.mdx
index 9a792552..c43fb54f 100644
--- a/app/materialize/guides/recommended-architecture/page.mdx
+++ b/app/materialize/guides/recommended-architecture/page.mdx
@@ -7,11 +7,13 @@ import { Callout } from "nextra/components";
# Recommended Architecture
-## Consuming Client
+
+
+## Event Processor

-Customers will need to build a client to act as an "event processor" that consumes permission updates and writes those updates to a datastore like Postgres.
+Customers will need to build a client to act as an event processor that consumes permission updates and writes those updates to a datastore like Postgres.
The consumer should be designed with resumability in mind by keeping track of the last revision consumed, just as any other stream processor.
## Durability
diff --git a/app/materialize/guides/relational-database/page.mdx b/app/materialize/guides/relational-database/page.mdx
index 1b093d83..45e92372 100644
--- a/app/materialize/guides/relational-database/page.mdx
+++ b/app/materialize/guides/relational-database/page.mdx
@@ -3,24 +3,17 @@ title: "Syncing to a Relational Database"
description: "Store Materialize permission sets in member_to_set and set_to_set tables and use SQL joins for authorization-aware filtering, with worked examples."
---
-import { Callout } from "nextra/components";
-
# Syncing to a Relational Database
-Just as with relational database materialized views, you need to provide Materialize with the "queries" you'd like it to pre-compute.
-The configuration is described as a list of `resource#permission@subject` tuples.
-Example:
+
+
+Materialize is configured with a list of [watched permissions](../concepts/watched-permissions) — the `resource#permission@subject` tuples it pre-computes. This guide assumes you've already configured watched permissions like:
```zed
resource#view@user
resource#edit@user
```
-
- During early access provisioning, Materialize instances are not self-service, so you'll need to
- provide the permissions to be computed by Materialize directly to your AuthZed account team.
-
-
## Relational Database
You can find a runnable version of these examples [here](https://dbfiddle.uk/dX10Cu3Z).
@@ -157,12 +150,12 @@ Storing from [LookupPermissionSets]/[WatchPermissionSets] in this model requires
```sql
INSERT INTO user_to_set (user_id, parent_set)
VALUES ('123', 'document:123#view'),
- ('123', 'group:shared#member'),
- ('456', 'group:shared#member');
+ ('123', 'group:engineering#member'),
+ ('456', 'group:engineering#member');
INSERT INTO set_to_document_view (child_set, document_id)
VALUES ('document:123#view', '123'),
- ('group:shared#member', '456');
+ ('group:engineering#member', '456');
```
Note that an extra entry (`document:123#view`, `123`) was added to simplify the join side (avoiding the `left join` in the previous example).
diff --git a/components/docs-home.css b/components/docs-home.css
index 4ddb1c97..ed9e891d 100644
--- a/components/docs-home.css
+++ b/components/docs-home.css
@@ -25,22 +25,9 @@
--stone-900: 300 24% 94%;
--stone-950: 300 30% 96%;
--stone-975: 300 40% 98%;
- /* ---- Accents — LIGHT (darker shades so they read on a light ground) ---- */
- --magenta-300: 316 50% 45%;
- --magenta-400: 316 48% 47%;
- --magenta-500: 316 50% 44%;
- --magenta-600: 316 52% 40%;
- --magenta-950: 315 33.3% 7.1%;
- --red-400: 352 70% 48%;
- --teal-300: 178 40% 32%;
- --teal-400: 178 38% 35%;
- --teal-500: 178 35% 33%;
- --teal-700: 178 30% 26%;
- --violet-400: 253 60% 52%;
- --violet-500: 253 62% 50%;
- --violet-700: 255 50% 46%;
- --sand-300: 30 75% 42%;
- --blue-500: 200 75% 40%;
+ /* ---- Accents (magenta/red/teal/violet/sand/blue) now live site-wide on
+ `:root` + `html.dark` in app/globals.css, so components outside this page
+ can use them too. Both light and dark values moved together. ---- */
/* ---- Shared structural tokens ---- */
--gradient-brand-warm: linear-gradient(
to right,
@@ -81,21 +68,7 @@ html.dark .docs-home {
--stone-900: 278 28.6% 11%;
--stone-950: 280 36.6% 8%;
--stone-975: 282 50% 3.9%;
- --magenta-300: 318 41.2% 70%;
- --magenta-400: 317 40.2% 60%;
- --magenta-500: 316 39.2% 51%;
- --magenta-600: 316 42% 45%;
- --magenta-950: 315 33.3% 7.1%;
- --red-400: 355 91% 73.9%;
- --teal-300: 175 27.5% 72.9%;
- --teal-400: 176 28% 56%;
- --teal-500: 177 28.2% 45.9%;
- --teal-700: 178 30% 30%;
- --violet-400: 253 76.5% 70%;
- --violet-500: 253 73.4% 63.1%;
- --violet-700: 255 58% 43.9%;
- --sand-300: 28 100% 72%;
- --blue-500: 195 73.4% 63.1%;
+ /* Dark accents also moved to app/globals.css (`html.dark`). */
background:
radial-gradient(120% 80% at 88% -10%, hsl(var(--magenta-950) / 0.55), transparent 60%),
diff --git a/components/feature-badge.css b/components/feature-badge.css
new file mode 100644
index 00000000..486016c5
--- /dev/null
+++ b/components/feature-badge.css
@@ -0,0 +1,62 @@
+/* Materialize feature badges — clickable pill linking back to the feature's
+ definition on the Overview page. Text casing, font weight, border opacity,
+ and padding are adopted from Sandworm's status-badge.tsx
+ (github.com/authzed/design/tree/main/sandworm/components/ui) for family
+ resemblance, even though that component's semantics (content lifecycle:
+ ready/draft/deprecated/under-review) differ from ours (feature identity +
+ release maturity). Colors are teal (Accelerated Queries) and violet
+ (Event Streams), read from the shared Sandworm accent tokens in
+ app/globals.css so they can't drift from the rest of the site. */
+
+/* The row owns the vertical rhythm so two pills (Snapshots) sit on one line
+ without doubling the margin between them. */
+.feature-badge-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin: 1.5rem 0 1.75rem;
+}
+
+.feature-badge {
+ display: inline-block;
+ padding: 0.25rem 0.75rem;
+ border: 1px solid;
+ border-radius: 9999px;
+ font-size: 0.75rem;
+ font-weight: 500;
+ text-decoration: none;
+}
+
+/* Points at the early-access note in the TOC rail. Nudged up rather than set
+ as so it doesn't stretch the pill's line box. */
+.feature-badge-mark {
+ display: inline-block;
+ margin-left: 0.15em;
+ font-size: 0.9em;
+ line-height: 1;
+ vertical-align: 0.28em;
+}
+
+.feature-badge-sr {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+/* No light/dark variants needed — --teal-500 and --violet-500 flip themselves
+ under html.dark (app/globals.css). */
+.feature-badge-accelerated-queries {
+ color: hsl(var(--teal-500));
+ background: hsl(var(--teal-500) / 0.1);
+ border-color: hsl(var(--teal-500) / 0.5);
+}
+
+.feature-badge-event-streams {
+ color: hsl(var(--violet-500));
+ background: hsl(var(--violet-500) / 0.1);
+ border-color: hsl(var(--violet-500) / 0.5);
+}
diff --git a/components/feature-badge.tsx b/components/feature-badge.tsx
new file mode 100644
index 00000000..98fbfc5e
--- /dev/null
+++ b/components/feature-badge.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import "./feature-badge.css";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { FEATURES, EARLY_ACCESS, featuresForPath } from "@/lib/materialize-features";
+import { FeatureNotes } from "./feature-notes";
+
+/* Marks which Materialize feature a page belongs to. Used in MDX as a bare
+ right under the H1 (mdx-components.ts) — the feature is
+ read from the route via lib/materialize-features.ts, so a page that spans
+ both features (Snapshots) renders both pills without repeating itself.
+
+ Identity, not status: a pill has no room to say what "Early Access" means
+ or who to contact, so it carries an asterisk instead, and the note in the
+ TOC rail (feature-notes.tsx) tells that story in the matching colour. */
+export function FeatureBadge() {
+ const features = featuresForPath(usePathname());
+ if (features.length === 0) return null;
+
+ return (
+ <>
+
+ {features.map((key) => {
+ const { label, href } = FEATURES[key];
+ return (
+
+ {label}
+ {EARLY_ACCESS[key] && (
+ <>
+
+ *
+
+ (in early access)
+ >
+ )}
+
+ );
+ })}
+
+ {/* Shown only below Nextra's TOC breakpoint, where the rail copy is
+ hidden and the asterisk would otherwise point at nothing. */}
+
+ >
+ );
+}
diff --git a/components/feature-notes.css b/components/feature-notes.css
new file mode 100644
index 00000000..be566688
--- /dev/null
+++ b/components/feature-notes.css
@@ -0,0 +1,87 @@
+/* Early-access note, tinted with the same Sandworm accent token as the pill it
+ belongs to (app/globals.css), so the pill's asterisk and this note read as
+ one marker. Deliberately quieter than a Nextra : a hairline rule
+ rather than a filled box. */
+
+.feature-notes {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+/* Exactly one placement is ever visible. Nextra hides the TOC column below
+ 80rem (`x:max-xl:hidden` in nextra-theme-docs/style.css) — matching that
+ number here is what keeps the asterisk from pointing at nothing on a
+ laptop. If Nextra ever moves its breakpoint, this has to move with it. */
+.feature-notes-rail {
+ margin-top: 1.5rem;
+}
+@media (width < 80rem) {
+ .feature-notes-rail {
+ display: none;
+ }
+}
+
+.feature-notes-inline {
+ margin: -0.75rem 0 1.75rem;
+ max-width: 46rem;
+}
+@media (width >= 80rem) {
+ .feature-notes-inline {
+ display: none;
+ }
+}
+
+.feature-note {
+ border-left: 2px solid;
+ padding: 0.125rem 0 0.125rem 0.625rem;
+}
+
+.feature-note-head {
+ display: flex;
+ align-items: baseline;
+ gap: 0.2em;
+ margin: 0 0 0.15rem;
+ font-size: 0.75rem;
+ font-weight: 500;
+ line-height: 1.35;
+}
+
+.feature-note-mark {
+ font-size: 0.9em;
+ line-height: 1;
+}
+
+.feature-note-body {
+ margin: 0;
+ font-size: 0.6875rem;
+ line-height: 1.5;
+ color: #6b7280;
+}
+html.dark .feature-note-body {
+ color: #9ca3af;
+}
+
+.feature-note-body a {
+ color: inherit;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+.feature-note-body a:hover {
+ text-decoration-thickness: 2px;
+}
+
+/* Colour identity — matches .feature-badge-* one for one. */
+.feature-note-accelerated-queries {
+ border-color: hsl(var(--teal-500) / 0.5);
+}
+.feature-note-accelerated-queries .feature-note-head {
+ color: hsl(var(--teal-500));
+}
+
+.feature-note-event-streams {
+ border-color: hsl(var(--violet-500) / 0.5);
+}
+.feature-note-event-streams .feature-note-head {
+ color: hsl(var(--violet-500));
+}
diff --git a/components/feature-notes.tsx b/components/feature-notes.tsx
new file mode 100644
index 00000000..21b5d5c4
--- /dev/null
+++ b/components/feature-notes.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import "./feature-notes.css";
+import { usePathname } from "next/navigation";
+import { FEATURES, EARLY_ACCESS, featuresForPath } from "@/lib/materialize-features";
+
+const DEDICATED = "https://authzed.com/docs/authzed/guides/picking-a-product#dedicated";
+
+/* The early-access story for whichever feature(s) the current page documents —
+ what the asterisk on the pill means. Renders nothing outside Materialize.
+
+ Rendered TWICE, in two placements, with CSS showing exactly one:
+
+ rail — docked in the TOC column, the preferred home
+ inline — directly under the pills, for narrow viewports
+
+ Nextra hides the TOC below 80rem (`x:max-xl:hidden`), so on a laptop or
+ phone the rail copy is gone and the asterisk would point at nothing. The
+ inline copy takes over at exactly that breakpoint. Both are in the DOM;
+ `display: none` keeps the hidden one out of the accessibility tree too.
+
+ Tinted with the same feature token as its pill (app/globals.css) so the
+ pill and the note read as one marker, not two unrelated notices. */
+export function FeatureNotes({ placement }: { placement: "rail" | "inline" }) {
+ const features = featuresForPath(usePathname()).filter((key) => EARLY_ACCESS[key]);
+ if (features.length === 0) return null;
+
+ return (
+
+ {features.map((key) => (
+
+ ))}
+
+ );
+}
diff --git a/components/toc-extra-content.tsx b/components/toc-extra-content.tsx
index 5128bd01..f243717d 100644
--- a/components/toc-extra-content.tsx
+++ b/components/toc-extra-content.tsx
@@ -1,9 +1,13 @@
import { Feedback } from "@/components/feedback";
import { TocCTA } from "@/components/cta";
+import { FeatureNotes } from "@/components/feature-notes";
export function TocExtraContent() {
return (
<>
+ {/* Page-specific, so it sits above the generic Cloud prompt. Renders
+ nothing outside the Materialize section. */}
+
>
diff --git a/lib/changed-pages.json b/lib/changed-pages.json
index 88183dbf..2688d6dc 100644
--- a/lib/changed-pages.json
+++ b/lib/changed-pages.json
@@ -1,4 +1,19 @@
{
+ "/materialize/api/client-sdks": {
+ "status": "updated"
+ },
+ "/materialize/api/lookup-permission-sets": {
+ "status": "updated"
+ },
+ "/materialize/api/watch-permission-sets": {
+ "status": "updated"
+ },
+ "/materialize/concepts/hydration": {
+ "status": "new"
+ },
+ "/materialize/concepts/managing-client-state": {
+ "status": "updated"
+ },
"/materialize/concepts/permission-set-lifecycle": {
"status": "updated"
},
@@ -7,5 +22,20 @@
},
"/materialize/concepts/snapshots": {
"status": "updated"
+ },
+ "/materialize/concepts/watched-permissions": {
+ "status": "new"
+ },
+ "/materialize/getting-started/overview": {
+ "status": "updated"
+ },
+ "/materialize/guides/recommended-architecture": {
+ "status": "updated"
+ },
+ "/materialize/guides/relational-database": {
+ "status": "updated"
+ },
+ "/materialize/getting-started/limitations": {
+ "status": "updated"
}
}
diff --git a/lib/materialize-features.ts b/lib/materialize-features.ts
new file mode 100644
index 00000000..aab8454f
--- /dev/null
+++ b/lib/materialize-features.ts
@@ -0,0 +1,52 @@
+/* Which Materialize feature each page documents.
+
+ Single source of truth for both markers: the inline pill under the H1
+ (components/feature-badge.tsx) and the early-access note in the TOC rail
+ (components/feature-notes.tsx). The rail lives in a global Nextra slot
+ that can't see page props, so both read the route instead — that way the
+ pill and the note can never disagree about what a page belongs to.
+
+ Paths are basePath-relative: usePathname() strips NEXT_PUBLIC_BASE_DIR. */
+
+export const FEATURES = {
+ "accelerated-queries": {
+ label: "Accelerated Queries",
+ href: "/materialize/getting-started/overview#accelerated-queries",
+ },
+ "event-streams": {
+ label: "Event Streams",
+ href: "/materialize/getting-started/overview#event-streams",
+ },
+} as const;
+
+export type FeatureKey = keyof typeof FEATURES;
+
+/* Every feature is in early access today, so the note's text doesn't vary.
+ It's per-feature anyway because the note is colour-matched to its pill —
+ and because these will go GA on different dates. */
+export const EARLY_ACCESS: Record = {
+ "accelerated-queries": true,
+ "event-streams": true,
+};
+
+const PAGE_FEATURES: Record = {
+ "/materialize/api/client-sdks": ["event-streams"],
+ "/materialize/api/lookup-permission-sets": ["event-streams"],
+ "/materialize/api/watch-permission-sets": ["event-streams"],
+ "/materialize/api/download-permission-sets": ["event-streams"],
+ "/materialize/concepts/managing-client-state": ["event-streams"],
+ "/materialize/concepts/permission-set-lifecycle": ["event-streams"],
+ "/materialize/concepts/permission-sets": ["event-streams"],
+ "/materialize/concepts/snapshots": ["accelerated-queries", "event-streams"],
+ "/materialize/concepts/hydration": ["accelerated-queries", "event-streams"],
+ "/materialize/concepts/watched-permissions": ["accelerated-queries", "event-streams"],
+ "/materialize/getting-started/limitations": ["accelerated-queries", "event-streams"],
+ "/materialize/guides/recommended-architecture": ["event-streams"],
+ "/materialize/guides/relational-database": ["event-streams"],
+};
+
+export function featuresForPath(pathname: string | null): readonly FeatureKey[] {
+ if (!pathname) return [];
+ const clean = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
+ return PAGE_FEATURES[clean] ?? [];
+}
diff --git a/mdx-components.ts b/mdx-components.ts
index 9533ba64..699f653a 100644
--- a/mdx-components.ts
+++ b/mdx-components.ts
@@ -1,6 +1,7 @@
import { useMDXComponents as getDocsMDXComponents } from "nextra-theme-docs";
import type { Component } from "react";
import { Yes, No } from "@/components/feature-icon";
+import { FeatureBadge } from "@/components/feature-badge";
const docsComponents = getDocsMDXComponents();
@@ -8,5 +9,6 @@ export const useMDXComponents = (components?: Component) => ({
...docsComponents,
Yes,
No,
+ FeatureBadge,
...components,
});
diff --git a/public/images/materialize-architecture.svg b/public/images/materialize-architecture.svg
new file mode 100644
index 00000000..61fe3e7f
--- /dev/null
+++ b/public/images/materialize-architecture.svg
@@ -0,0 +1,147 @@
+
diff --git a/public/images/materialize-client-state-diagram.svg b/public/images/materialize-client-state-diagram.svg
new file mode 100644
index 00000000..5cbb370c
--- /dev/null
+++ b/public/images/materialize-client-state-diagram.svg
@@ -0,0 +1,69 @@
+