diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc
index 7ebcbfe9..d21f8164 100644
--- a/.oxfmtrc.jsonc
+++ b/.oxfmtrc.jsonc
@@ -8,5 +8,8 @@
"app/spicedb/concepts/commands/page.mdx",
// Autogenerated
"app/spicedb/getting-started/installing-zed/page.mdx",
+ // oxfmt de-indents the list inside , which breaks nextra's MDX
+ // compile ("expected closing tag "). Leave these hand-formatted.
+ "app/materialize/concepts/snapshots/page.mdx",
],
}
diff --git a/app/_meta.ts b/app/_meta.ts
index bc2eaffa..c7679d71 100644
--- a/app/_meta.ts
+++ b/app/_meta.ts
@@ -4,21 +4,33 @@ export default {
index: {
title: "Documentation",
display: "hidden",
+ theme: {
+ layout: "full",
+ toc: false,
+ sidebar: false,
+ breadcrumb: false,
+ pagination: false,
+ timestamp: false,
+ copyPage: false,
+ },
},
spicedb: {
- title: "SpiceDB Documentation",
+ title: "SpiceDB",
type: "page",
},
authzed: {
- title: "AuthZed Product Documentation",
+ title: "Managed SpiceDB",
type: "page",
},
- "best-practices": {
- title: "Best Practices",
+ materialize: {
+ title: "Materialize",
type: "page",
},
mcp: {
title: "MCP",
type: "page",
},
+ changes: {
+ display: "hidden",
+ },
} satisfies MetaRecord;
diff --git a/app/authzed/concepts/authzed-materialize/page.mdx b/app/authzed/concepts/authzed-materialize/page.mdx
deleted file mode 100644
index 0c8a51af..00000000
--- a/app/authzed/concepts/authzed-materialize/page.mdx
+++ /dev/null
@@ -1,610 +0,0 @@
-import { Callout } from "nextra/components";
-
-# AuthZed Materialize
-
-
- AuthZed Materialize 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 to your own application database to do operations like searching, sorting, and filtering much more efficiently.
-
-In summary, AuthZed Materialize allows you to:
-
-- Speed up `CheckPermission` and `CheckBulkPermissions`.
-- Speed up `LookupResources` and `LookupSubjects`, especially when there is a large number of resources.
-- Build authorization-aware UIs, e.g. by providing a filtered and/or sorted list of more than several thousand authorized objects.
-- Perform ACL filtering in other secondary indexes, like a search index (e.g. Elasticsearch).
-
-[Dedicated]: ../guides/picking-a-product#dedicated
-
-## 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
- - [.all intersections]
-
-- [Expiring relationships] aren't supported.
-- Materialize takes time to compute the denormalized relationship updates, so if you are streaming the changes to your database, your application must be able to tolerate some lag.
-
-[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
-[expiring relationships]: https://authzed.com/docs/spicedb/concepts/expiring-relationships
-[Dedicated]: ../guides/picking-a-product#dedicated
-
-## Client SDK
-
-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)
-- [authzed-java 0.10.0](https://github.com/authzed/authzed-java/releases/tag/0.10.0)
-- [authzed-py v0.17.0](https://github.com/authzed/authzed-py/releases/tag/v0.17.0)
-- [authzed-rb v0.11.0](https://github.com/authzed/authzed-rb/releases/tag/v0.11.0)
-- [authzed-node v0.17.0](https://github.com/authzed/authzed-node/releases/tag/v0.17.0)
-
-AuthZed Materialize's gRPC API definition is available from [API version 1.35](https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0)
-
-## Recommended Architecture
-
-### Consuming Client
-
-
-
-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
-
-Every SpiceDB permission update will come with a `ZedToken`.
-The consumer must keep track of that revision token to be able to resume the change stream from the last event consumed when a failure happens, like stream disconnection, consumer restart, or server-side restarts.
-
-When a consumer failure happens, the process should determine the last revision `ZedToken` consumed, and send that alongside your request.
-The consumer should be coded with idempotency in mind in the event of such failures, meaning it should be prepared to process stream messages that have already been processed.
-
-Storing the revision `ZedToken` in the same database where the computed permissions are being stored is a good practice as it enables storing those transactionally, which gives you the guarantee that whatever revision the consumer restarts from, won’t cause events to be skipped, which would lead to an inconsistent state of the world.
-
-There may be scenarios where a revision has so many changes that storing transactionally can degrade the performance/availability of the target database.
-In situations like these, one may want to store the events in batches, and in such cases, the revision should only be stored when the consumer determines the last batch has been processed.
-If a failure happened in between those batches, the consumer will be able to restart processing from the start of the revision and idempotently overwrite whatever events were already in place.
-
-
- Change events are stored up to 24h to make sure Materialize storage does not grow unbounded and
- affect its performance.
-
-
-## Configuration
-
-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:
-
-```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).
-
-These are tables you likely already have in your database
-
-1. something representing the user
-2. something representing the object we want to filter
-
-```sql
-CREATE TABLE users (
- id varchar(100) PRIMARY KEY,
- name varchar(40)
-);
-CREATE TABLE documents (
- id varchar(100) PRIMARY KEY,
- name varchar(40),
- contents_bucket varchar(100)
-);
-```
-
-The `member_to_set` and `set_to_set` tables below are just used to track data from [LookupPermissionSets] and [WatchPermissionSets], all you need to do is store the fields directly from those APIs.
-
-```sql
-CREATE TABLE member_to_set (
- member_type varchar(100),
- member_id varchar(100),
- member_relation varchar(100),
- set_type varchar(100),
- set_id varchar(100),
- set_relation varchar(100)
-);
-
-CREATE TABLE set_to_set (
- child_type varchar(100),
- child_id varchar(100),
- child_relation varchar(100),
- parent_type varchar(100),
- parent_id varchar(100),
- parent_relation varchar(100)
-);
-```
-
-Seed some base data; this would already exist in the application:
-
-```sql
-INSERT INTO users (id, name) VALUES ('123', 'evan'), ('456', 'victor');
-INSERT INTO documents (id, name) VALUES ('123', 'evan secret doc'), ('456', 'victor shared doc');
-```
-
-Sync data from [LookupPermissionSets]/[WatchPermissionSets].
-The APIs return type/id/relation name:
-
-```sql
-INSERT INTO member_to_set (member_type, member_id, member_relation, set_type, set_id, set_relation)
- VALUES ('user', '123', '', 'document', '123', 'view'),
- ('user', '123', '', 'group', 'shared', 'member'),
- ('user', '456', '', 'group', 'shared', 'member');
-
-INSERT INTO set_to_set (child_type, child_id, child_relation, parent_type, parent_id, parent_relation)
- VALUES ('group', 'shared', 'member', 'document', '456', 'view');
-```
-
-To query, join the local application data with [LookupPermissionSets]/[WatchPermissionSets] data to filter by specific permissions.
-
-Find all documents `evan` can `view:`
-
-```sql
-SELECT d.id FROM documents d
- LEFT JOIN set_to_set s2s ON d.id = s2s.parent_id
- INNER JOIN member_to_set m2s ON (m2s.set_id = s2s.child_id AND m2s.set_type = s2s.child_type AND m2s.set_relation = s2s.child_relation) OR (d.id = m2s.set_id )
- INNER JOIN users u ON u.id = m2s.member_id
- WHERE
- u.name = 'evan' AND
- m2s.member_type = 'user' AND
- m2s.member_relation = '' AND ((
- s2s.parent_type = 'document' AND
- s2s.parent_relation='view'
- ) OR (
- m2s.set_type = 'document' AND
- m2s.set_relation = 'view'
- ));
-```
-
-| id |
-| :-- |
-| 123 |
-| 456 |
-
-The same query, by changing only the username, will find all documents `victor` can `view`:
-
-```sql
-SELECT d.id FROM documents d
- LEFT JOIN set_to_set s2s ON d.id = s2s.parent_id
- INNER JOIN member_to_set m2s ON (m2s.set_id = s2s.child_id AND m2s.set_type = s2s.child_type AND m2s.set_relation = s2s.child_relation) OR (d.id = m2s.set_id )
- INNER JOIN users u ON u.id = m2s.member_id
- WHERE
- u.name = 'victor' AND
- m2s.member_type = 'user' AND
- m2s.member_relation = '' AND ((
- s2s.parent_type = 'document' AND
- s2s.parent_relation='view'
- ) OR (
- m2s.set_type = 'document' AND
- m2s.set_relation = 'view'
- ));
-```
-
-| id |
-| :-- |
-| 456 |
-
-The above example shows the most flexible way to do this: you can update your SpiceDB schema and sync new permission sets data without SQL schema changes but at the cost of more verbose SQL queries.
-
-If you know that you only care about `document#view@user,` then you can store the data more concisely and query more simply.
-This strategy can also be used to shard the data coming from the Materialize APIs so that it does not all land in one table.
-
-Simplified permission sets storage (just for `document#view@user`):
-
-```sql
-CREATE TABLE user_to_set (
- user_id varchar(100),
- parent_set varchar(300)
-);
-
-CREATE TABLE set_to_document_view (
- child_set varchar(300),
- document_id varchar(100)
-);
-```
-
-Storing from [LookupPermissionSets]/[WatchPermissionSets] in this model requires some simple transformations compared to the previous example:
-
-```sql
-INSERT INTO user_to_set (user_id, parent_set)
- VALUES ('123', 'document:123#view'),
- ('123', 'group:shared#member'),
- ('456', 'group:shared#member');
-
-INSERT INTO set_to_document_view (child_set, document_id)
- VALUES ('document:123#view', '123'),
- ('group:shared#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).
-The queries are a bit simpler, though they can't be used to answer any permission check other than `document#view@user`.
-
-Find all documents `evan` can `view`:
-
-```sql
-SELECT d.id FROM documents d
- INNER JOIN set_to_document_view s2s ON d.id = s2s.document_id
- INNER JOIN user_to_set m2s ON m2s.parent_set = s2s.child_set
- INNER JOIN users u ON u.id = m2s.user_id
- WHERE u.name = 'evan';
-```
-
-| id |
-| :-- |
-| 123 |
-| 456 |
-
-Find all documents `victor` can `view`:
-
-```sql
-SELECT d.id FROM documents d
- INNER JOIN set_to_document_view s2s ON d.id = s2s.document_id
- INNER JOIN user_to_set m2s ON m2s.parent_set = s2s.child_set
- INNER JOIN users u ON u.id = m2s.user_id
- WHERE u.name = 'victor';
-```
-
-| id |
-| :-- |
-| 456 |
-
-## API Specification
-
-### [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.
-
-The API consists of various event types that capture deltas that occurred since a client started listening.
-It will also notify of events like a [breaking schema change] that necessitate rebuilding of the index.
-
-#### Request
-
-```json
-{
- "optional_starting_after": "the_zed_token"
-}
-```
-
-The `optional_starting_after` field in the request denotes the SpiceDB revision to start streaming changes.
-It will start streaming from the revision right after the indicated one.
-If no `optional_starting_after` is provided, Materialize will determine the latest revision at the moment of the request, and start streaming changes from there on.
-
-#### Response
-
-##### Revision Checkpoint Event
-
-Sent when changes happened in SpiceDB, but didn't affect Materialize.
-Customers should keep track of this revision in their internal database to know where to resume from in the event of stream disconnection or stream consumer restart/failure.
-
-```json
-{
- "completed_revision": {
- "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA=="
- }
-}
-```
-
-##### Member Added To Set Event
-
-```json
-{
- "change": {
- "at_revision": {
- "token": "GiAKHjE3MTUzMzkzMDg0MTY2NzUxNzcuMDAwMDAwMDAwMA=="
- },
- "operation": "SET_OPERATION_ADDED",
- "parent_set": {
- "object_type": "thumper/resource",
- "object_id": "seconddoc",
- "permission_or_relation": "reader"
- },
- "child_member": {
- "object_type": "thumper/user",
- "object_id": "fred",
- "optional_permission_or_relation": ""
- }
- }
-}
-```
-
-##### Member Removed From Set Event
-
-```json
-{
- "change": {
- "at_revision": {
- "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA=="
- },
- "operation": "SET_OPERATION_REMOVED",
- "parent_set": {
- "object_type": "thumper/resource",
- "object_id": "seconddoc",
- "permission_or_relation": "reader"
- },
- "child_member": {
- "object_type": "thumper/user",
- "object_id": "fred",
- "optional_permission_or_relation": ""
- }
- }
-}
-```
-
-##### Set Added To Set Event
-
-```json
-{
- "change": {
- "at_revision": {
- "token": "GiAKHjE3MTUzMzkzMDg0MTY2NzUxNzcuMDAwMDAwMDAwMA=="
- },
- "operation": "SET_OPERATION_ADDED",
- "parent_set": {
- "object_type": "thumper/resource",
- "object_id": "seconddoc",
- "permission_or_relation": "reader"
- },
- "child_set": {
- "object_type": "thumper/team",
- "object_id": "engineering",
- "permission_or_relation": "members"
- }
- }
-}
-```
-
-##### Set Removed From Set Event
-
-```json
-{
- "change": {
- "at_revision": {
- "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA=="
- },
- "operation": "SET_OPERATION_REMOVED",
- "parent_set": {
- "object_type": "thumper/resource",
- "object_id": "seconddoc",
- "permission_or_relation": "reader"
- },
- "child_set": {
- "object_type": "thumper/team",
- "object_id": "engineering",
- "permission_or_relation": "members"
- }
- }
-}
-```
-
-##### [Breaking Schema Change] Event
-
-When the origin SpiceDB instance introduces a schema change that invalidates all currently computed permission sets, Materialize will issue a special event indicating this happened:
-
-```json
-{
- "breaking_schema_change": {
- "change_at": {
- "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA=="
- }
- }
-}
-```
-
-The event indicates the revision at which the schema change happened.
-
-When the client receives this event, all previously indexed permission sets are rendered stale, and the client must rebuild the index with a call to [LookupPermissionSets] at the revision the schema change was introduced.
-
-Not every change to the origin permission system schema is considered breaking.
-
-###### Detecting Breaking Schema Changes In Development Environment
-
-The AuthZed team has optimized Materialize to reduce the number of instances where a change is considered breaking and thus renders permission set stale.
-To determine if a schema change is breaking, we provide the `materialize-cli` tool.
-
-
- `materialize-cli` is still in early development, please reach out to us if you want to try it as
- part of AuthZed Materialize early access.
-
-
-#### Errors
-
-##### FailedPrecondition: Revision Does Not Exist
-
-Whenever the client receives a `FailedPrecondition`, they should retry with a backoff.
-In this case, the client is asking for a revision that hasn’t been yet processed by Materialize.
-You may receive this error when:
-
-- the Materialize instances are restarting and catching up with all changes that have happened since it took a snapshot of your SpiceDB instance.
-- A [BreakingSchemaChange] was emitted, and by happenstance, your client had to reconnect.
- The Materialize server hasn’t yet rebuilt a new snapshot of your SpiceDB instance with the new schema to serve new events.
-
-### [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.
-
-The API is resumable via cursors, meaning that the client is responsible for specifying the cursor that denotes the permission set to start streaming from, and the number of them to be streamed.
-If no cursor is provided, Materialize will start streaming from what it considers the first change.
-The number of events to stream is required.
-
-The API also supports specifying an optional revision via the `optional_at_revision`, which indicates Materialize should start streaming events at a revision at least as fresh as the provided one.
-The server will guarantee the revision selected is equal to or more recent than the requested revision.
-This is useful when the client has been notified a [breaking schema change] occurred and that they should rebuild their indexes.
-If both `optional_at_revision` and `optional_starting_after` are provided, the latter always takes precedence.
-
-
- Client **must** provide the revision token after a [breaking schema change] through
- `optional_starting_after`, otherwise Materialize will start streaming permission sets for whatever
- snapshot revision is available at the moment, and won't reflect the schema changes.
-
-
-The current cursor is provided with each event in the stream, so if the consumer client crashes it knows where to restart from, alongside the revision at which the data is computed.
-Once an event is received, the recommended course of action is to store the following as part of the same transaction in your database:
-
-- The data to insert into the permission sets table
-- The cursor into a table denoting the current state of the backfill
-- The current revision token into a table denoting the snapshot revision of the stored Materialize data
-
-In the event of the customer consumer being restarted, it should:
-
-- Select the current cursor from the backfill cursor table
-- Issue a [LookupPermissionSets] request with `optional_starting_after` set to the stored cursor
-- Resume ingestion as usual
-
-
- While AuthZed treats correctness very seriously, bugs may be identified that affect the
- correctness of the denormalized permissions computed by Materialize. Those incidents should be
- rare, but consumers must have all the machinery in place to re-index via [LookupPermissionSets] at
- any given time.
-
-
-#### Reindexing After A Breaking Schema Change
-
-Another scenario for invoking [LookupPermissionSets] is after a [breaking schema change] written to the origin SpiceDB instance.
-In this case, the index is rendered stale and a client must rebuild it by calling [LookupPermissionSets] at the revision the schema change was introduced.
-During this period, the previously ingested permission sets data will be stale.
-We are working on several options to minimize the lag caused and improve the developer experience:
-
-- On-Band: Stream breaking schema changes over [WatchPermissionSets] instead of requiring a [LookupPermissionSets] call.
- This will reduce the amount of changes to stream and reduce the time to reindex.
-- Off-Band: Support Staging Schemas in SpiceDB so that your application can call [LookupPermissionSets] over the staged schema changes
-
-These are the two recommended strategies to handle breaking schema events in your application:
-
-##### On-Band LookupPermissionSets Ingestion
-
-With on-band ingestion, your application reindexes all permission set data right after receiving a [breaking schema change].
-This will naturally lead to lag, but depending on the volume of data, your application may be able to withstand this.
-The tradeoff here is development velocity versus lag.
-If your application can't withstand lag during reindexing, please consider the off-band strategy.
-
-In this scenario, we recommend using the _versioned permission set tables strategy_: your application will keep track of various versions of the permission set.
-One will be the currently ingested and being updated with [WatchPermissionSets], and the new version is the result of a [BreakingSchemaChange] and is ingested with [LookupPermissionSets] while the previous version of the permission sets are being served.
-You should keep track of what is the current revision being served.
-
-##### Off-Band LookupPermissionSets Ingestion
-
-With an off-band ingestion strategy the client will avoid the lag by following a strategy similar to non-breaking relational database migrations: by transforming your schema following a four-phase migration.
-
-A new permission will be written to your SpiceDB schema that includes the changes, and it will be added to a new Materialize instance run in parallel to the current one, similar to a blue/green deployment.
-You will be able to run [LookupPermissionSets] against the new instance to obtain all the permission sets plus the ones corresponding to the newly added permission.
-Once your index is ingested and is updated with [WatchPermissionSets], your application should be able to switch to use the new permission and the old permission can be dropped from Materialize first, and then from your schema.
-
-This strategy requires more steps and careful planning, but in exchange completely avoids any lag.
-
-
- For the time being, Materialize instances are not self-serve, so you'll need to work with your
- Account Team to execute the off-band ingestion strategy.
-
-
-#### Request
-
-```json
-{
- "limit": "the_number_of_events_to_stream",
- "optional_at_revision": "minimum revision to start streaming from",
- "optional_starting_after": "continue stream from the specified cursor"
-}
-```
-
-#### Response
-
-##### Permission Set Sent Over The Stream
-
-```json
-{
- "change": {
- "at_revision": {
- "token": "GiAKHjE3MTUzMzk0Mzg2MjA4NzI1MDIuMDAwMDAwMDAwMA=="
- },
- "operation": "SET_OPERATION_ADDED",
- "parent_set": {
- "object_type": "thumper/resource",
- "object_id": "seconddoc",
- "permission_or_relation": "reader"
- },
- "child_member": {
- "object_type": "thumper/user",
- "object_id": "tom",
- "optional_permission_or_relation": ""
- }
- },
- "cursor": {
- "limit": 10000,
- "token": {
- "token": "GiAKHjE3MTUzMzk0Mzg2MjA4NzI1MDIuMDAwMDAwMDAwMA=="
- },
- "starting_index": 1,
- "completed_members": false
- }
-}
-```
-
-The payload comes with the permission set data to store in your database table, and the cursor that points to that permission set in case resumption is necessary.
-The computed revision is also provided as part of the request via `at_revision` so that once the permission set is streamed, the consumer knows where to start streaming [WatchPermissionSets] from.
-
-The consumer should continue to stream permission sets indefinitely until it has not received further messages over the stream.
-Please note that the server may return `EOF` to denote the stream is closed, but that does not mean there aren't more changes to serve.
-The client **must** open a new stream with the last cursor, and continue streaming until an iteration of the stream yielded zero events.
-At this point, the backfill is completed, and the consumer can start processing change events using [WatchPermissionSets], using the stored snapshot revision.
-
-#### Errors
-
-##### InvalidArgument: Cursor Limit Does Not Match Request Limit
-
-The limit specified in the request, and the limit specified in the initiating request that led to the currently provided cursor differ.
-To solve this, make sure you use the same limit for the initiating request as for every subsequent request.
-The limit is optional once you provide a cursor since it’s stored in it.
-
-##### FailedPrecondition: Snapshot Not Found For Revision, Try Again Later
-
-Whenever the client receives a `FailedPrecondition`, they should retry with a backoff.
-In this case, the client is asking for a revision that hasn’t been yet processed by Materialize.
-You may receive this error when your client calls [LookupPermissionSets] right after receiving [BreakingSchemaChange] through the WatchPermissionsSets API.
-The client should retry with the same revision later on.
-
-##### Aborted: Requested Revision Is No Longer Available
-
-This error is returned when a new Materialize has deployed a new snapshot of the origin SpiceDB permission system.
-This happens on a regular cadence and is part of Materialize's internal maintenance operations.
-When this error is returned, it indicates the client should restart [LookupPermissionSets] afresh, dropping the cursor in `optional_starting_after`, and also dropping `optional_at_revision`.
-Every previously stored data should also be discarded.
-If the volume of data to ingest via [LookupPermissionSets] is large enough it takes many hours to consume, please get in touch with AuthZed support to tweak your instance accordingly.
-
-### 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.
-
-
-
-[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
-[LookupResources]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupResources
-[LookupSubjects]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupSubjects
-[BreakingSchemaChange]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
-[Breaking Schema Change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
-[breaking schema change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
diff --git a/app/authzed/guides/picking-a-product/page.mdx b/app/authzed/guides/picking-a-product/page.mdx
index 22565d8c..879dabc6 100644
--- a/app/authzed/guides/picking-a-product/page.mdx
+++ b/app/authzed/guides/picking-a-product/page.mdx
@@ -10,26 +10,26 @@ This document is designed to give a high-level overview of the features supporte
| Feature | [Open Source] | [Cloud] | [Dedicated] | [Enterprise] |
| ---------------------------- | :-----------: | :-----: | :---------: | :----------: |
-| Self-Hosted | ✅ | ❌ | ❌ | ✅ |
-| No-commit pricing | ✅ | ✅ | ❌ | ❌ |
-| [Materialize (Early Access)] | ❌ | ❌ | ✅ | ❌ |
-| [Management Dashboard] | ❌ | ✅ | ✅ | ❌ |
-| [Private Networking] | DIY | ❌ | ✅ | DIY |
-| [Workload Isolation] | DIY | ✅ | ✅ | DIY |
-| [Automated Updates] | DIY | ✅ | ✅ | DIY |
-| [SOC2 Compliance] | DIY | ✅ | ✅ | DIY |
-| [Audit Logging] | ❌ | ✅ | ✅ | ✅ |
-| [Rate Limiting] | ❌ | ✅ | ✅ | ✅ |
-| [Multi-Region Deployments] | DIY | ❌ | ✅ | DIY |
-| [Security Embargo] | ❌ | ✅ | ✅ | ✅ |
-| [Restricted API Access] | DIY | ✅ | ✅ | ✅ |
-| [Expedited Support] | ❌ | ✅ | ✅ | ✅ |
+| Self-Hosted | | | | |
+| No-commit pricing | | | | |
+| [Materialize (Early Access)] | | | | |
+| [Management Dashboard] | | | | |
+| [Private Networking] | DIY | | | DIY |
+| [Workload Isolation] | DIY | | | DIY |
+| [Automated Updates] | DIY | | | DIY |
+| [SOC2 Compliance] | DIY | | | DIY |
+| [Audit Logging] | | | | |
+| [Rate Limiting] | | | | |
+| [Multi-Region Deployments] | DIY | | | DIY |
+| [Security Embargo] | | | | |
+| [Restricted API Access] | DIY | | | |
+| [Expedited Support] | | | | |
[Cloud]: #cloud
[Dedicated]: #dedicated
[Enterprise]: #enterprise
[Open Source]: #open-source
-[Materialize (Early Access)]: ../concepts/authzed-materialize
+[Materialize (Early Access)]: /materialize/getting-started/overview
[Audit Logging]: ../concepts/audit-logging
[Automated Updates]: ../concepts/update-channels
[Management Dashboard]: ../concepts/management-dashboard
diff --git a/app/authzed/guides/postgres-fdw/page.mdx b/app/authzed/guides/postgres-fdw/page.mdx
index 01f4e993..3cceb35d 100644
--- a/app/authzed/guides/postgres-fdw/page.mdx
+++ b/app/authzed/guides/postgres-fdw/page.mdx
@@ -443,7 +443,7 @@ The FDW has some limitations to be aware of:
For super-fast joins or checks on large datasets, consider [AuthZed
- Materialize](/authzed/concepts/authzed-materialize). Once set up, Materialize works seamlessly
+ Materialize](/materialize/getting-started/overview). Once set up, Materialize works seamlessly
with the FDW with no SQL changes required to your queries.
@@ -477,4 +477,4 @@ If queries return no results:
- Learn more about [Restricted API Access](/authzed/concepts/restricted-api-access)
- Explore [SpiceDB concepts](/spicedb/concepts/schema) to better understand your data
-- Review [best practices](/best-practices) for production deployments
+- Review [best practices](/spicedb/best-practices) for production deployments
diff --git a/app/changes/page.tsx b/app/changes/page.tsx
new file mode 100644
index 00000000..e86e6dcf
--- /dev/null
+++ b/app/changes/page.tsx
@@ -0,0 +1,55 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import changed from "@/lib/changed-pages.json";
+
+export const metadata = { title: "Changed in this branch" };
+
+// Review aid only — never reachable on production (matches the empty-manifest
+// gate in scripts/build-changed-manifest.mjs).
+const SHOW = process.env.NODE_ENV !== "production" || process.env.VERCEL_ENV === "preview";
+
+type Entry = { status: "new" | "updated" };
+const MANIFEST = changed as Record;
+
+// Title-case the last route segment as a readable label.
+function label(route: string): string {
+ const seg = route.split("/").filter(Boolean).pop() ?? route;
+ return seg.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
+}
+
+export default function ChangesPage() {
+ if (!SHOW) notFound();
+ const routes = Object.keys(MANIFEST).sort();
+ const created = routes.filter((r) => MANIFEST[r].status === "new");
+ const updated = routes.filter((r) => MANIFEST[r].status === "updated");
+
+ const Section = ({ title, items }: { title: string; items: string[] }) =>
+ items.length === 0 ? null : (
+
+
+ {title} ({items.length})
+
+
+ {items.map((r) => (
+
+ {label(r)}{" "}
+ {r}
+
+ ))}
+
+
+ );
+
+ return (
+
+
Changed in this branch
+
+ Content pages that are new or modified versus the published docs (origin/main).
+ Styling and component changes are excluded.
+ {routes.length === 0 && " No content changes detected."}
+
+
+
+
+ );
+}
diff --git a/public/favicon.ico b/app/favicon.ico
similarity index 100%
rename from public/favicon.ico
rename to app/favicon.ico
diff --git a/app/globals.css b/app/globals.css
index 75256b1f..b1c19af9 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -103,3 +103,141 @@ html.dark-mode {
.swagger-ui {
background-color: transparent;
}
+
+/* ── Announcement bar — terminal style matching the marketing site ───────
+ Neutralize Nextra's default banner chrome and let our inner bar own the
+ full-bleed background, border, padding, and typography. Sandworm tokens
+ (stone-950 bg, stone-800 border, sand-300 accents, #fff text). */
+.nextra-banner {
+ padding: 0 !important;
+ background: transparent !important;
+ border: 0 !important;
+ color: inherit !important;
+ justify-content: flex-start !important;
+ text-align: left !important;
+}
+.docs-announce {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-start;
+ align-items: flex-start;
+ text-align: left;
+ gap: 0.5rem;
+ width: 100%;
+ padding: 0.75rem 1.25rem;
+ background: hsl(280 36.6% 8%); /* sandworm stone-950 */
+ border-bottom: 2px solid hsl(279 11.5% 23.9%); /* sandworm stone-800 */
+ color: #fff;
+ font-size: 0.8125rem;
+ line-height: 1.4;
+ font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
+}
+@media (min-width: 1024px) {
+ .docs-announce {
+ flex-wrap: nowrap;
+ align-items: center;
+ }
+}
+.docs-announce .da-prompt,
+.docs-announce .da-bracket,
+.docs-announce .da-star,
+.docs-announce .da-spinner {
+ color: hsl(28 100% 72%); /* sand-300 */
+ flex-shrink: 0;
+}
+/* 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 {
+ content: "☱";
+}
+@media (prefers-reduced-motion: no-preference) {
+ .docs-announce.spin .da-spinner::before {
+ animation: da-cursor 0.3s steps(1, end) infinite;
+ }
+ @keyframes da-cursor {
+ 0% {
+ content: "☱";
+ }
+ 33.33% {
+ content: "☲";
+ }
+ 66.66% {
+ content: "☴";
+ }
+ }
+}
+.docs-announce .da-body {
+ min-width: 0;
+ flex: 1;
+}
+.docs-announce .da-body a {
+ color: #fff;
+ text-decoration: underline;
+ white-space: nowrap;
+ transition: font-weight 0.1s ease;
+}
+.docs-announce .da-body a:hover {
+ font-weight: 700;
+}
+.docs-announce .da-meta {
+ display: none;
+ flex-shrink: 0;
+ align-items: center;
+ gap: 0.5rem;
+}
+@media (min-width: 1024px) {
+ .docs-announce .da-meta {
+ display: flex;
+ }
+}
+.docs-announce .da-meta a {
+ color: hsl(283 4% 66.1%);
+ text-decoration: none;
+} /* stone-300 */
+.docs-announce .da-meta a:hover {
+ text-decoration: underline;
+}
+
+/* ── Navbar logo — append a muted mono "/docs" path label (à la "/spicedb").
+ Inherits the navbar text colour so it adapts to light/dark automatically. */
+.docs-logo {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+}
+.docs-logo-slug {
+ font-family: ui-monospace, "JetBrains Mono", "SF Mono", Menlo, monospace;
+ font-size: 1.05rem;
+ font-weight: 500;
+ line-height: 1;
+ letter-spacing: -0.01em;
+ color: currentColor;
+ opacity: 0.5;
+}
+
+/* ── Navbar CTA — "Book a demo" promoted from the page TOC to the top nav so
+ the primary conversion action is persistent instead of lost beside a short
+ table of contents. Sandworm magenta; white text reads in both themes. */
+.nav-cta {
+ display: none; /* desktop-only — see media query below */
+ align-items: center;
+ padding: 0.35rem 0.85rem;
+ border-radius: 0.5rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ line-height: 1;
+ white-space: nowrap;
+ color: #fff;
+ background: hsl(316 50% 44%);
+ transition: background-color 0.15s ease;
+}
+.nav-cta:hover {
+ background: hsl(316 52% 38%);
+}
+/* Only show the CTA once there's room for it alongside the 5 nav tabs +
+ icons; below this it would push the nav into overflow. */
+@media (min-width: 1024px) {
+ .nav-cta {
+ display: inline-flex;
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 5b46f693..6ee65702 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -8,6 +8,7 @@ import LogoIcon from "@/components/icons/logo-icon.svg";
import BannerContents from "@/components/banner";
import Providers from "@/components/providers";
import { TocExtraContent } from "@/components/toc-extra-content";
+import { ContentStatus } from "@/components/content-status";
import Scripts from "@/components/scripts";
import type { Metadata, ResolvingMetadata } from "next";
import { SpeedInsights } from "@vercel/speed-insights/next";
@@ -42,32 +43,65 @@ export const generateMetadata = async (
};
};
+/** SpiceDB GitHub star count, cached hourly; falls back to a sane default. */
+async function getStarCount(): Promise {
+ try {
+ const res = await fetch("https://api.github.com/repos/authzed/spicedb", {
+ next: { revalidate: 3600 },
+ headers: { Accept: "application/vnd.github+json" },
+ });
+ if (!res.ok) throw new Error(String(res.status));
+ const n = (await res.json())?.stargazers_count;
+ if (typeof n !== "number") throw new Error("no count");
+ return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
+ } catch {
+ return "5.7k";
+ }
+}
+
export default async function RootLayout({ children }) {
const pageMap = await getPageMap();
const enableSearch = process.env.NEXT_PUBLIC_ENABLE_SEARCH_BLOG_INTEGRATION === "true";
+ const starCount = await getStarCount();
const navbar = (
}
- logoLink="https://authzed.com"
+ logo={
+
+
+ /docs
+
+ }
+ logoLink="/"
chatLink="https://authzed.com/discord"
projectLink="https://github.com/authzed/spicedb"
- />
+ >
+
+ Book a demo
+
+
);
return (
-
+
}
navbar={navbar}
@@ -94,7 +128,10 @@ export default async function RootLayout({ children }) {
>
- {children}
+
+
+ {children}
+
diff --git a/app/materialize/_meta.ts b/app/materialize/_meta.ts
new file mode 100644
index 00000000..075a9e44
--- /dev/null
+++ b/app/materialize/_meta.ts
@@ -0,0 +1,6 @@
+export default {
+ "getting-started": "Getting Started",
+ concepts: "Concepts",
+ guides: "Guides",
+ api: "API Reference",
+};
diff --git a/app/materialize/api/_meta.ts b/app/materialize/api/_meta.ts
new file mode 100644
index 00000000..065e4263
--- /dev/null
+++ b/app/materialize/api/_meta.ts
@@ -0,0 +1,5 @@
+export default {
+ "client-sdks": "Client SDKs",
+ "watch-permission-sets": "WatchPermissionSets",
+ "lookup-permission-sets": "LookupPermissionSets",
+};
diff --git a/app/materialize/api/client-sdks/page.mdx b/app/materialize/api/client-sdks/page.mdx
new file mode 100644
index 00000000..3ac2b2d4
--- /dev/null
+++ b/app/materialize/api/client-sdks/page.mdx
@@ -0,0 +1,11 @@
+# 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)
+- [authzed-java 0.10.0](https://github.com/authzed/authzed-java/releases/tag/0.10.0)
+- [authzed-py v0.17.0](https://github.com/authzed/authzed-py/releases/tag/v0.17.0)
+- [authzed-rb v0.11.0](https://github.com/authzed/authzed-rb/releases/tag/v0.11.0)
+- [authzed-node v0.17.0](https://github.com/authzed/authzed-node/releases/tag/v0.17.0)
+
+AuthZed Materialize's gRPC API definition is available from [API version 1.35](https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0)
diff --git a/app/materialize/api/lookup-permission-sets/page.mdx b/app/materialize/api/lookup-permission-sets/page.mdx
new file mode 100644
index 00000000..dc3ea04f
--- /dev/null
+++ b/app/materialize/api/lookup-permission-sets/page.mdx
@@ -0,0 +1,162 @@
+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.
+
+The API is resumable via cursors, meaning that the client is responsible for specifying the cursor that denotes the permission set to start streaming from, and the number of them to be streamed.
+If no cursor is provided, Materialize will start streaming from what it considers the first change.
+The number of events to stream is required.
+
+The API also supports specifying an optional revision via the `optional_at_revision`, which indicates Materialize should start streaming events at a revision at least as fresh as the provided one.
+The server will guarantee the revision selected is equal to or more recent than the requested revision.
+This is useful when the client has been notified a [breaking schema change] occurred and that they should rebuild their indexes.
+If both `optional_at_revision` and `optional_starting_after` are provided, the latter always takes precedence.
+
+
+ Client **must** provide the revision token after a [breaking schema change] through
+ `optional_starting_after`, otherwise Materialize will start streaming permission sets for whatever
+ snapshot revision is available at the moment, and won't reflect the schema changes.
+
+
+The current cursor is provided with each event in the stream, so if the consumer client crashes it knows where to restart from, alongside the revision at which the data is computed.
+Once an event is received, the recommended course of action is to store the following as part of the same transaction in your database:
+
+- The data to insert into the permission sets table
+- The cursor into a table denoting the current state of the backfill
+- The current revision token into a table denoting the snapshot revision of the stored Materialize data
+
+In the event of the customer consumer being restarted, it should:
+
+- Select the current cursor from the backfill cursor table
+- Issue a [LookupPermissionSets] request with `optional_starting_after` set to the stored cursor
+- Resume ingestion as usual
+
+
+ While AuthZed treats correctness very seriously, bugs may be identified that affect the
+ correctness of the denormalized permissions computed by Materialize. Those incidents should be
+ rare, but consumers must have all the machinery in place to re-index via [LookupPermissionSets] at
+ any given time.
+
+
+## Reindexing After A Breaking Schema Change
+
+Another scenario for invoking [LookupPermissionSets] is after a [breaking schema change] written to the origin SpiceDB instance.
+In this case, the index is rendered stale and a client must rebuild it by calling [LookupPermissionSets] at the revision the schema change was introduced.
+During this period, the previously ingested permission sets data will be stale.
+We are working on several options to minimize the lag caused and improve the developer experience:
+
+- On-Band: Stream breaking schema changes over [WatchPermissionSets] instead of requiring a [LookupPermissionSets] call.
+ This will reduce the amount of changes to stream and reduce the time to reindex.
+- Off-Band: Support Staging Schemas in SpiceDB so that your application can call [LookupPermissionSets] over the staged schema changes
+
+These are the two recommended strategies to handle breaking schema events in your application:
+
+### On-Band LookupPermissionSets Ingestion
+
+With on-band ingestion, your application reindexes all permission set data right after receiving a [breaking schema change].
+This will naturally lead to lag, but depending on the volume of data, your application may be able to withstand this.
+The tradeoff here is development velocity versus lag.
+If your application can't withstand lag during reindexing, please consider the off-band strategy.
+
+In this scenario, we recommend using the _versioned permission set tables strategy_: your application will keep track of various versions of the permission set.
+One will be the currently ingested and being updated with [WatchPermissionSets], and the new version is the result of a [BreakingSchemaChange] and is ingested with [LookupPermissionSets] while the previous version of the permission sets are being served.
+You should keep track of what is the current revision being served.
+
+### Off-Band LookupPermissionSets Ingestion
+
+With an off-band ingestion strategy the client will avoid the lag by following a strategy similar to non-breaking relational database migrations: by transforming your schema following a four-phase migration.
+
+A new permission will be written to your SpiceDB schema that includes the changes, and it will be added to a new Materialize instance run in parallel to the current one, similar to a blue/green deployment.
+You will be able to run [LookupPermissionSets] against the new instance to obtain all the permission sets plus the ones corresponding to the newly added permission.
+Once your index is ingested and is updated with [WatchPermissionSets], your application should be able to switch to use the new permission and the old permission can be dropped from Materialize first, and then from your schema.
+
+This strategy requires more steps and careful planning, but in exchange completely avoids any lag.
+
+
+ For the time being, Materialize instances are not self-serve, so you'll need to work with your
+ Account Team to execute the off-band ingestion strategy.
+
+
+## Request
+
+```json
+{
+ "limit": "the_number_of_events_to_stream",
+ "optional_at_revision": "minimum revision to start streaming from",
+ "optional_starting_after": "continue stream from the specified cursor"
+}
+```
+
+## Response
+
+### Permission Set Sent Over The Stream
+
+```json
+{
+ "change": {
+ "at_revision": {
+ "token": "GiAKHjE3MTUzMzk0Mzg2MjA4NzI1MDIuMDAwMDAwMDAwMA=="
+ },
+ "operation": "SET_OPERATION_ADDED",
+ "parent_set": {
+ "object_type": "thumper/resource",
+ "object_id": "seconddoc",
+ "permission_or_relation": "reader"
+ },
+ "child_member": {
+ "object_type": "thumper/user",
+ "object_id": "tom",
+ "optional_permission_or_relation": ""
+ }
+ },
+ "cursor": {
+ "limit": 10000,
+ "token": {
+ "token": "GiAKHjE3MTUzMzk0Mzg2MjA4NzI1MDIuMDAwMDAwMDAwMA=="
+ },
+ "starting_index": 1,
+ "completed_members": false
+ }
+}
+```
+
+The payload comes with the permission set data to store in your database table, and the cursor that points to that permission set in case resumption is necessary.
+The computed revision is also provided as part of the request via `at_revision` so that once the permission set is streamed, the consumer knows where to start streaming [WatchPermissionSets] from.
+
+The consumer should continue to stream permission sets indefinitely until it has not received further messages over the stream.
+Please note that the server may return `EOF` to denote the stream is closed, but that does not mean there aren't more changes to serve.
+The client **must** open a new stream with the last cursor, and continue streaming until an iteration of the stream yielded zero events.
+At this point, the backfill is completed, and the consumer can start processing change events using [WatchPermissionSets], using the stored snapshot revision.
+
+## Errors
+
+### InvalidArgument: Cursor Limit Does Not Match Request Limit
+
+The limit specified in the request, and the limit specified in the initiating request that led to the currently provided cursor differ.
+To solve this, make sure you use the same limit for the initiating request as for every subsequent request.
+The limit is optional once you provide a cursor since it's stored in it.
+
+### FailedPrecondition: Snapshot Not Found For Revision, Try Again Later
+
+Whenever the client receives a `FailedPrecondition`, they should retry with a backoff.
+In this case, the client is asking for a revision that hasn't been yet processed by Materialize.
+You may receive this error when your client calls [LookupPermissionSets] right after receiving [BreakingSchemaChange] through the WatchPermissionsSets API.
+The client should retry with the same revision later on.
+
+### Aborted: Requested Revision Is No Longer Available
+
+This error is returned when a new Materialize has deployed a new snapshot of the origin SpiceDB permission system.
+This happens on a regular cadence and is part of Materialize's internal maintenance operations.
+When this error is returned, it indicates the client should restart [LookupPermissionSets] afresh, dropping the cursor in `optional_starting_after`, and also dropping `optional_at_revision`.
+Every previously stored data should also be discarded.
+If the volume of data to ingest via [LookupPermissionSets] is large enough it takes many hours to consume, please get in touch with AuthZed support to tweak your instance accordingly.
+
+[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
+[LookupResources]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupResources
+[LookupSubjects]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupSubjects
+[BreakingSchemaChange]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
+[Breaking Schema Change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
+[breaking schema change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
diff --git a/app/materialize/api/watch-permission-sets/page.mdx b/app/materialize/api/watch-permission-sets/page.mdx
new file mode 100644
index 00000000..710b753d
--- /dev/null
+++ b/app/materialize/api/watch-permission-sets/page.mdx
@@ -0,0 +1,179 @@
+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.
+
+The API consists of various event types that capture deltas that occurred since a client started listening.
+It will also notify of events like a [breaking schema change] that necessitate rebuilding of the index.
+
+## Request
+
+```json
+{
+ "optional_starting_after": "the_zed_token"
+}
+```
+
+The `optional_starting_after` field in the request denotes the SpiceDB revision to start streaming changes.
+It will start streaming from the revision right after the indicated one.
+If no `optional_starting_after` is provided, Materialize will determine the latest revision at the moment of the request, and start streaming changes from there on.
+
+## Response
+
+### Revision Checkpoint Event
+
+Sent when changes happened in SpiceDB, but didn't affect Materialize.
+Customers should keep track of this revision in their internal database to know where to resume from in the event of stream disconnection or stream consumer restart/failure.
+
+```json
+{
+ "completed_revision": {
+ "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA=="
+ }
+}
+```
+
+### Member Added To Set Event
+
+```json
+{
+ "change": {
+ "at_revision": {
+ "token": "GiAKHjE3MTUzMzkzMDg0MTY2NzUxNzcuMDAwMDAwMDAwMA=="
+ },
+ "operation": "SET_OPERATION_ADDED",
+ "parent_set": {
+ "object_type": "thumper/resource",
+ "object_id": "seconddoc",
+ "permission_or_relation": "reader"
+ },
+ "child_member": {
+ "object_type": "thumper/user",
+ "object_id": "fred",
+ "optional_permission_or_relation": ""
+ }
+ }
+}
+```
+
+### Member Removed From Set Event
+
+```json
+{
+ "change": {
+ "at_revision": {
+ "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA=="
+ },
+ "operation": "SET_OPERATION_REMOVED",
+ "parent_set": {
+ "object_type": "thumper/resource",
+ "object_id": "seconddoc",
+ "permission_or_relation": "reader"
+ },
+ "child_member": {
+ "object_type": "thumper/user",
+ "object_id": "fred",
+ "optional_permission_or_relation": ""
+ }
+ }
+}
+```
+
+### Set Added To Set Event
+
+```json
+{
+ "change": {
+ "at_revision": {
+ "token": "GiAKHjE3MTUzMzkzMDg0MTY2NzUxNzcuMDAwMDAwMDAwMA=="
+ },
+ "operation": "SET_OPERATION_ADDED",
+ "parent_set": {
+ "object_type": "thumper/resource",
+ "object_id": "seconddoc",
+ "permission_or_relation": "reader"
+ },
+ "child_set": {
+ "object_type": "thumper/team",
+ "object_id": "engineering",
+ "permission_or_relation": "members"
+ }
+ }
+}
+```
+
+### Set Removed From Set Event
+
+```json
+{
+ "change": {
+ "at_revision": {
+ "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA=="
+ },
+ "operation": "SET_OPERATION_REMOVED",
+ "parent_set": {
+ "object_type": "thumper/resource",
+ "object_id": "seconddoc",
+ "permission_or_relation": "reader"
+ },
+ "child_set": {
+ "object_type": "thumper/team",
+ "object_id": "engineering",
+ "permission_or_relation": "members"
+ }
+ }
+}
+```
+
+### [Breaking Schema Change] Event
+
+When the origin SpiceDB instance introduces a schema change that invalidates all currently computed permission sets, Materialize will issue a special event indicating this happened:
+
+```json
+{
+ "breaking_schema_change": {
+ "change_at": {
+ "token": "GiAKHjE3MTUzMzkzMTAzODQ2NDMxNzguMDAwMDAwMDAwMA=="
+ }
+ }
+}
+```
+
+The event indicates the revision at which the schema change happened.
+
+When the client receives this event, all previously indexed permission sets are rendered stale, and the client must rebuild the index with a call to [LookupPermissionSets] at the revision the schema change was introduced.
+
+Not every change to the origin permission system schema is considered breaking.
+
+#### Detecting Breaking Schema Changes In Development Environment
+
+The AuthZed team has optimized Materialize to reduce the number of instances where a change is considered breaking and thus renders permission set stale.
+To determine if a schema change is breaking, we provide the `materialize-cli` tool.
+
+
+ `materialize-cli` is still in early development, please reach out to us if you want to try it as
+ part of AuthZed Materialize early access.
+
+
+## Errors
+
+### FailedPrecondition: Revision Does Not Exist
+
+Whenever the client receives a `FailedPrecondition`, they should retry with a backoff.
+In this case, the client is asking for a revision that hasn't been yet processed by Materialize.
+You may receive this error when:
+
+- the Materialize instances are restarting and catching up with all changes that have happened since it took a snapshot of your SpiceDB instance.
+- A [BreakingSchemaChange] was emitted, and by happenstance, your client had to reconnect.
+ The Materialize server hasn't yet rebuilt a new snapshot of your SpiceDB instance with the new schema to serve new events.
+
+[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
+[LookupResources]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupResources
+[LookupSubjects]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupSubjects
+[BreakingSchemaChange]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
+[Breaking Schema Change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
+[breaking schema change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
diff --git a/app/materialize/concepts/_meta.ts b/app/materialize/concepts/_meta.ts
new file mode 100644
index 00000000..008bf1bc
--- /dev/null
+++ b/app/materialize/concepts/_meta.ts
@@ -0,0 +1,6 @@
+export default {
+ "permission-sets": "Permission Sets",
+ hydration: "Hydration",
+ 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..1cecab77
--- /dev/null
+++ b/app/materialize/concepts/hydration/page.mdx
@@ -0,0 +1,39 @@
+import { Callout } from "nextra/components";
+
+# Hydration
+
+
+ **Draft — content review needed.** This concept page was promoted from prose that previously lived
+ inline in the API reference. The mechanics below are accurate to the existing docs; the framing is
+ new and should be reviewed (and expanded with diagrams/examples) before publishing.
+
+
+**Hydration** is the process of populating your application's local copy of permission data for the first time — the initial backfill that gets you from an empty index to a complete, queryable [Permission Sets](./permission-sets) store.
+
+When you first bring a system online, you have no permission data locally.
+Hydration reads the **current** state of every permission set Materialize is configured to watch, using [LookupPermissionSets](../../api/lookup-permission-sets), and writes it into your datastore.
+Once hydration is complete, you switch to [WatchPermissionSets](../../api/watch-permission-sets) to keep that data current — see [Snapshots](./snapshots) for how the handoff stays consistent.
+
+## The hydration lifecycle
+
+1. **Backfill** — call [LookupPermissionSets](../../api/lookup-permission-sets), paging 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 hydration and live updates.
+
+
+ Hydration is not only a first-run concern. You must re-hydrate after a **breaking schema change**,
+ which renders previously indexed permission sets stale. Build the machinery to re-run hydration at
+ any time. See [Managing Client State](./managing-client-state).
+
+
+## Hydration and snapshots
+
+Hydration always produces a [snapshot](./snapshots) — a point-in-time, internally consistent view of the permission sets at a specific revision.
+The relationship is one-directional: **hydration is the act; a snapshot is the result.**
+You hydrate _to_ a snapshot revision, then keep that snapshot live with the change stream.
+
+
+ **TODO (Sam):** expand with a concrete worked example and the four-phase / blue-green re-hydration
+ strategy for breaking schema changes, currently documented under
+ [LookupPermissionSets](../../api/lookup-permission-sets).
+
diff --git a/app/materialize/concepts/managing-client-state/page.mdx b/app/materialize/concepts/managing-client-state/page.mdx
new file mode 100644
index 00000000..dda561ab
--- /dev/null
+++ b/app/materialize/concepts/managing-client-state/page.mdx
@@ -0,0 +1,13 @@
+# 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.
+
+
+
+[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
+[LookupResources]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupResources
+[LookupSubjects]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupSubjects
+[BreakingSchemaChange]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
+[Breaking Schema Change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
+[breaking schema change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
diff --git a/app/materialize/concepts/permission-sets/page.mdx b/app/materialize/concepts/permission-sets/page.mdx
new file mode 100644
index 00000000..54b479d1
--- /dev/null
+++ b/app/materialize/concepts/permission-sets/page.mdx
@@ -0,0 +1,32 @@
+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.
+
+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`).
+
+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.
+
+## How you obtain permission sets
+
+Permission sets reach your application through two complementary APIs:
+
+- [LookupPermissionSets](../../api/lookup-permission-sets) — reads the **current** permission sets as an initial backfill. See [Hydration](./hydration).
+- [WatchPermissionSets](../../api/watch-permission-sets) — streams **changes** to permission sets as relationships and schema evolve. See [Snapshots](./snapshots) for how the two stay consistent.
+
+## Storing permission sets
+
+The shape of the data maps cleanly onto relational tables (a `member_to_set` and a `set_to_set` table is the most flexible model), or into a secondary index such as Elasticsearch for ACL-filtered search.
+See [Syncing to a Relational Database](../../guides/relational-database) for worked examples.
+
+
+ The fields returned by `LookupPermissionSets` and `WatchPermissionSets` are identical — you store
+ them the same way regardless of which API delivered them. That symmetry is what lets you hydrate
+ with one API and keep current with the other.
+
diff --git a/app/materialize/concepts/snapshots/page.mdx b/app/materialize/concepts/snapshots/page.mdx
new file mode 100644
index 00000000..230f4985
--- /dev/null
+++ b/app/materialize/concepts/snapshots/page.mdx
@@ -0,0 +1,39 @@
+import { Callout } from "nextra/components";
+
+# Snapshots
+
+
+ **Draft — content review needed.** Promoted from prose previously scattered through the API
+ reference. Mechanics are accurate to the existing docs; framing is new and should be reviewed
+ before publishing.
+
+
+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:
+
+- [Hydration](./hydration) reads a snapshot in full via [LookupPermissionSets](../../api/lookup-permission-sets).
+- [WatchPermissionSets](../../api/watch-permission-sets) advances that snapshot forward, delivering the deltas that move it from one revision to the next.
+
+## Why the revision matters
+
+Because each event carries its revision, your consumer can always answer "as of when is my local data correct?" and resume from exactly the right place after a crash or disconnect.
+Storing the snapshot revision **in the same transaction** as the permission data it describes is the core durability guarantee: whatever revision you restart from, no events are skipped and no inconsistent state is observable.
+
+## Snapshot lifecycle events
+
+- **Revision checkpoint** — SpiceDB changed, but nothing Materialize watches was affected. Advance your stored revision so you know where to resume.
+- **Breaking schema change** — the current snapshot is invalidated; you must build a fresh snapshot by re-[hydrating](./hydration) at the new revision.
+- **Snapshot rotation** — Materialize periodically deploys a new internal snapshot of the origin SpiceDB system as part of routine maintenance. A request against a retired snapshot returns `Aborted: Requested Revision Is No Longer Available`, signalling the consumer to re-hydrate from scratch.
+
+
+ After a breaking schema change you **must** pass the revision token through `optional_starting_after`
+ when re-hydrating, or Materialize will stream against whatever snapshot is current and your data
+ won't reflect the schema change.
+
+
+
+ **TODO (Sam):** confirm the exact snapshot retention window and rotation cadence, and whether the
+ 24h change-event retention is the same mechanism or distinct.
+
diff --git a/app/materialize/getting-started/_meta.ts b/app/materialize/getting-started/_meta.ts
new file mode 100644
index 00000000..3a8ce84f
--- /dev/null
+++ b/app/materialize/getting-started/_meta.ts
@@ -0,0 +1,4 @@
+export default {
+ overview: "Overview",
+ limitations: "Limitations",
+};
diff --git a/app/materialize/getting-started/limitations/page.mdx b/app/materialize/getting-started/limitations/page.mdx
new file mode 100644
index 00000000..f3e8c5fe
--- /dev/null
+++ b/app/materialize/getting-started/limitations/page.mdx
@@ -0,0 +1,15 @@
+# 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
+ - [.all intersections]
+
+- [Expiring relationships] aren't supported.
+- Materialize takes time to compute the denormalized relationship updates, so if you are streaming the changes to your database, your application must be able to tolerate some lag.
+
+[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
+[expiring relationships]: https://authzed.com/docs/spicedb/concepts/expiring-relationships
+[Dedicated]: https://authzed.com/docs/authzed/guides/picking-a-product#dedicated
diff --git a/app/materialize/getting-started/overview/page.mdx b/app/materialize/getting-started/overview/page.mdx
new file mode 100644
index 00000000..fc5a8062
--- /dev/null
+++ b/app/materialize/getting-started/overview/page.mdx
@@ -0,0 +1,23 @@
+import { Callout } from "nextra/components";
+
+# What is Materialize?
+
+
+ AuthZed Materialize 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 to your own application database to do operations like searching, sorting, and filtering much more efficiently.
+
+In summary, AuthZed Materialize allows you to:
+
+- Speed up `CheckPermission` and `CheckBulkPermissions`.
+- Speed up `LookupResources` and `LookupSubjects`, especially when there is a large number of resources.
+- Build authorization-aware UIs, e.g. by providing a filtered and/or sorted list of more than several thousand authorized objects.
+- Perform ACL filtering in other secondary indexes, like a search index (e.g. Elasticsearch).
+
+[Dedicated]: https://authzed.com/docs/authzed/guides/picking-a-product#dedicated
diff --git a/app/materialize/guides/_meta.ts b/app/materialize/guides/_meta.ts
new file mode 100644
index 00000000..f82819d5
--- /dev/null
+++ b/app/materialize/guides/_meta.ts
@@ -0,0 +1,4 @@
+export default {
+ "recommended-architecture": "Recommended Architecture",
+ "relational-database": "Syncing to a Relational Database",
+};
diff --git a/app/materialize/guides/recommended-architecture/page.mdx b/app/materialize/guides/recommended-architecture/page.mdx
new file mode 100644
index 00000000..2437630d
--- /dev/null
+++ b/app/materialize/guides/recommended-architecture/page.mdx
@@ -0,0 +1,37 @@
+import { Callout } from "nextra/components";
+
+# Recommended Architecture
+
+## Consuming Client
+
+
+
+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
+
+Every SpiceDB permission update will come with a `ZedToken`.
+The consumer must keep track of that revision token to be able to resume the change stream from the last event consumed when a failure happens, like stream disconnection, consumer restart, or server-side restarts.
+
+When a consumer failure happens, the process should determine the last revision `ZedToken` consumed, and send that alongside your request.
+The consumer should be coded with idempotency in mind in the event of such failures, meaning it should be prepared to process stream messages that have already been processed.
+
+Storing the revision `ZedToken` in the same database where the computed permissions are being stored is a good practice as it enables storing those transactionally, which gives you the guarantee that whatever revision the consumer restarts from, won't cause events to be skipped, which would lead to an inconsistent state of the world.
+
+There may be scenarios where a revision has so many changes that storing transactionally can degrade the performance/availability of the target database.
+In situations like these, one may want to store the events in batches, and in such cases, the revision should only be stored when the consumer determines the last batch has been processed.
+If a failure happened in between those batches, the consumer will be able to restart processing from the start of the revision and idempotently overwrite whatever events were already in place.
+
+
+ Change events are stored up to 24h to make sure Materialize storage does not grow unbounded and
+ affect its performance.
+
+
+[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
+[LookupResources]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupResources
+[LookupSubjects]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupSubjects
+[BreakingSchemaChange]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
+[Breaking Schema Change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
+[breaking schema change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
diff --git a/app/materialize/guides/relational-database/page.mdx b/app/materialize/guides/relational-database/page.mdx
new file mode 100644
index 00000000..66cced55
--- /dev/null
+++ b/app/materialize/guides/relational-database/page.mdx
@@ -0,0 +1,201 @@
+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:
+
+```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).
+
+These are tables you likely already have in your database
+
+1. something representing the user
+2. something representing the object we want to filter
+
+```sql
+CREATE TABLE users (
+ id varchar(100) PRIMARY KEY,
+ name varchar(40)
+);
+CREATE TABLE documents (
+ id varchar(100) PRIMARY KEY,
+ name varchar(40),
+ contents_bucket varchar(100)
+);
+```
+
+The `member_to_set` and `set_to_set` tables below are just used to track data from [LookupPermissionSets] and [WatchPermissionSets], all you need to do is store the fields directly from those APIs.
+
+```sql
+CREATE TABLE member_to_set (
+ member_type varchar(100),
+ member_id varchar(100),
+ member_relation varchar(100),
+ set_type varchar(100),
+ set_id varchar(100),
+ set_relation varchar(100)
+);
+
+CREATE TABLE set_to_set (
+ child_type varchar(100),
+ child_id varchar(100),
+ child_relation varchar(100),
+ parent_type varchar(100),
+ parent_id varchar(100),
+ parent_relation varchar(100)
+);
+```
+
+Seed some base data; this would already exist in the application:
+
+```sql
+INSERT INTO users (id, name) VALUES ('123', 'evan'), ('456', 'victor');
+INSERT INTO documents (id, name) VALUES ('123', 'evan secret doc'), ('456', 'victor shared doc');
+```
+
+Sync data from [LookupPermissionSets]/[WatchPermissionSets].
+The APIs return type/id/relation name:
+
+```sql
+INSERT INTO member_to_set (member_type, member_id, member_relation, set_type, set_id, set_relation)
+ VALUES ('user', '123', '', 'document', '123', 'view'),
+ ('user', '123', '', 'group', 'shared', 'member'),
+ ('user', '456', '', 'group', 'shared', 'member');
+
+INSERT INTO set_to_set (child_type, child_id, child_relation, parent_type, parent_id, parent_relation)
+ VALUES ('group', 'shared', 'member', 'document', '456', 'view');
+```
+
+To query, join the local application data with [LookupPermissionSets]/[WatchPermissionSets] data to filter by specific permissions.
+
+Find all documents `evan` can `view:`
+
+```sql
+SELECT d.id FROM documents d
+ LEFT JOIN set_to_set s2s ON d.id = s2s.parent_id
+ INNER JOIN member_to_set m2s ON (m2s.set_id = s2s.child_id AND m2s.set_type = s2s.child_type AND m2s.set_relation = s2s.child_relation) OR (d.id = m2s.set_id )
+ INNER JOIN users u ON u.id = m2s.member_id
+ WHERE
+ u.name = 'evan' AND
+ m2s.member_type = 'user' AND
+ m2s.member_relation = '' AND ((
+ s2s.parent_type = 'document' AND
+ s2s.parent_relation='view'
+ ) OR (
+ m2s.set_type = 'document' AND
+ m2s.set_relation = 'view'
+ ));
+```
+
+| id |
+| :-- |
+| 123 |
+| 456 |
+
+The same query, by changing only the username, will find all documents `victor` can `view`:
+
+```sql
+SELECT d.id FROM documents d
+ LEFT JOIN set_to_set s2s ON d.id = s2s.parent_id
+ INNER JOIN member_to_set m2s ON (m2s.set_id = s2s.child_id AND m2s.set_type = s2s.child_type AND m2s.set_relation = s2s.child_relation) OR (d.id = m2s.set_id )
+ INNER JOIN users u ON u.id = m2s.member_id
+ WHERE
+ u.name = 'victor' AND
+ m2s.member_type = 'user' AND
+ m2s.member_relation = '' AND ((
+ s2s.parent_type = 'document' AND
+ s2s.parent_relation='view'
+ ) OR (
+ m2s.set_type = 'document' AND
+ m2s.set_relation = 'view'
+ ));
+```
+
+| id |
+| :-- |
+| 456 |
+
+The above example shows the most flexible way to do this: you can update your SpiceDB schema and sync new permission sets data without SQL schema changes but at the cost of more verbose SQL queries.
+
+If you know that you only care about `document#view@user,` then you can store the data more concisely and query more simply.
+This strategy can also be used to shard the data coming from the Materialize APIs so that it does not all land in one table.
+
+Simplified permission sets storage (just for `document#view@user`):
+
+```sql
+CREATE TABLE user_to_set (
+ user_id varchar(100),
+ parent_set varchar(300)
+);
+
+CREATE TABLE set_to_document_view (
+ child_set varchar(300),
+ document_id varchar(100)
+);
+```
+
+Storing from [LookupPermissionSets]/[WatchPermissionSets] in this model requires some simple transformations compared to the previous example:
+
+```sql
+INSERT INTO user_to_set (user_id, parent_set)
+ VALUES ('123', 'document:123#view'),
+ ('123', 'group:shared#member'),
+ ('456', 'group:shared#member');
+
+INSERT INTO set_to_document_view (child_set, document_id)
+ VALUES ('document:123#view', '123'),
+ ('group:shared#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).
+The queries are a bit simpler, though they can't be used to answer any permission check other than `document#view@user`.
+
+Find all documents `evan` can `view`:
+
+```sql
+SELECT d.id FROM documents d
+ INNER JOIN set_to_document_view s2s ON d.id = s2s.document_id
+ INNER JOIN user_to_set m2s ON m2s.parent_set = s2s.child_set
+ INNER JOIN users u ON u.id = m2s.user_id
+ WHERE u.name = 'evan';
+```
+
+| id |
+| :-- |
+| 123 |
+| 456 |
+
+Find all documents `victor` can `view`:
+
+```sql
+SELECT d.id FROM documents d
+ INNER JOIN set_to_document_view s2s ON d.id = s2s.document_id
+ INNER JOIN user_to_set m2s ON m2s.parent_set = s2s.child_set
+ INNER JOIN users u ON u.id = m2s.user_id
+ WHERE u.name = 'victor';
+```
+
+| id |
+| :-- |
+| 456 |
+
+[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
+[LookupResources]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupResources
+[LookupSubjects]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.v1#authzed.api.v1.PermissionsService.LookupSubjects
+[BreakingSchemaChange]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
+[Breaking Schema Change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
+[breaking schema change]: https://buf.build/authzed/api/docs/v1.35.0:authzed.api.materialize.v0#authzed.api.materialize.v0.BreakingSchemaChange
diff --git a/app/page.mdx b/app/page.mdx
index 7012444e..568490b5 100644
--- a/app/page.mdx
+++ b/app/page.mdx
@@ -1,20 +1,7 @@
---
-title: Authzed Docs
+title: AuthZed Documentation
---
-Browse documentation for **SpiceDB** or **AuthZed Products** by selecting one below.
+import { DocsHomeShell } from "@/components/docs-home-shell";
-import { Cards } from "nextra/components";
-
-
-
-
-
SpiceDB Documentation
-
-
-
-
-
AuthZed Products Documentation
-
-
-
+
diff --git a/app/spicedb/_meta.ts b/app/spicedb/_meta.ts
index 9be8212d..98632821 100644
--- a/app/spicedb/_meta.ts
+++ b/app/spicedb/_meta.ts
@@ -5,6 +5,7 @@ export default {
ops: "Operations",
integrations: "Integrations",
tutorials: "Tutorials",
+ "best-practices": "Best Practices",
api: "API Reference",
links: "Links",
};
diff --git a/app/best-practices/_meta.ts b/app/spicedb/best-practices/_meta.ts
similarity index 100%
rename from app/best-practices/_meta.ts
rename to app/spicedb/best-practices/_meta.ts
diff --git a/app/best-practices/page.mdx b/app/spicedb/best-practices/page.mdx
similarity index 100%
rename from app/best-practices/page.mdx
rename to app/spicedb/best-practices/page.mdx
diff --git a/app/spicedb/concepts/querying-data/page.mdx b/app/spicedb/concepts/querying-data/page.mdx
index 8df40614..f86f314f 100644
--- a/app/spicedb/concepts/querying-data/page.mdx
+++ b/app/spicedb/concepts/querying-data/page.mdx
@@ -77,7 +77,7 @@ Receive many:
It's a good way to do [prefiltering of results](/spicedb/modeling/protecting-a-list-endpoint#filtering-with-lookupresources) in
a List endpoint, but it's a heavy request that can cause performance problems when more than 10k results are involved.
In that case we recommend postfiltering with [CheckBulk](#checkbulkpermissions), and if that still doesn't work,
-we recommend evaluating [Materialize](/authzed/concepts/authzed-materialize).
+we recommend evaluating [Materialize](/materialize/getting-started/overview).
## LookupSubjects
diff --git a/app/spicedb/concepts/read-after-write/page.mdx b/app/spicedb/concepts/read-after-write/page.mdx
index 7791fb14..9bbf58b5 100644
--- a/app/spicedb/concepts/read-after-write/page.mdx
+++ b/app/spicedb/concepts/read-after-write/page.mdx
@@ -108,5 +108,5 @@ Use `fully_consistent` only when you need a quick solution or when the request v
- [Consistency in SpiceDB](/spicedb/concepts/consistency)
- [Zed Tokens, Zookies, Consistency for Authorization](https://authzed.com/blog/zedtokens)
- [Hotspot Caching in Google Zanzibar and SpiceDB](https://authzed.com/blog/hotspot-caching-in-google-zanzibar-and-spicedb)
-- [Best Practices: Understand your consistency needs](/best-practices#understand-your-consistency-needs)
-- [Best Practices: Use ZedTokens and "At Least As Fresh"](/best-practices#use-zedtokens-and-at-least-as-fresh-for-best-caching)
+- [Best Practices: Understand your consistency needs](/spicedb/best-practices#understand-your-consistency-needs)
+- [Best Practices: Use ZedTokens and "At Least As Fresh"](/spicedb/best-practices#use-zedtokens-and-at-least-as-fresh-for-best-caching)
diff --git a/app/spicedb/getting-started/discovering-spicedb/page.mdx b/app/spicedb/getting-started/discovering-spicedb/page.mdx
index bd9eb976..94c6ff4c 100644
--- a/app/spicedb/getting-started/discovering-spicedb/page.mdx
+++ b/app/spicedb/getting-started/discovering-spicedb/page.mdx
@@ -75,7 +75,7 @@ Features that distinguish SpiceDB from other systems include:
- Deep observability with [Prometheus] metrics, [pprof] profiles, structured logging, and [OpenTelemetry] tracing
[gRPC]: https://buf.build/authzed/api/docs/main:authzed.api.v1
-[HTTP/JSON]: https://app.swaggerhub.com/apis-docs/authzed/authzed/1.0
+[HTTP/JSON]: /spicedb/api/http-api
[per request]: https://docs.authzed.com/reference/api-consistency
[New Enemy Problem]: https://authzed.com/blog/new-enemies/
[schema language]: https://docs.authzed.com/guides/schema
diff --git a/app/spicedb/modeling/attributes/page.mdx b/app/spicedb/modeling/attributes/page.mdx
index 5b0ea522..9f737697 100644
--- a/app/spicedb/modeling/attributes/page.mdx
+++ b/app/spicedb/modeling/attributes/page.mdx
@@ -50,7 +50,7 @@ To enable document editing, you need to establish a relationship that connects a
Wildcards are adequate for most binary attribute scenarios; however, wildcards are not currently
- supported by [Authzed Materialize](/authzed/concepts/authzed-materialize). Those who plan to use
+ supported by [Authzed Materialize](/materialize/getting-started/overview). Those who plan to use
Materialize should use loop relationships for binary attributes.
diff --git a/app/spicedb/modeling/validation-testing-debugging/page.mdx b/app/spicedb/modeling/validation-testing-debugging/page.mdx
index 8ceda168..3ba7e4b3 100644
--- a/app/spicedb/modeling/validation-testing-debugging/page.mdx
+++ b/app/spicedb/modeling/validation-testing-debugging/page.mdx
@@ -94,9 +94,9 @@ Below is an example of configuring a Check Watch:
Watches can show any of the following states:
-- ✅ Permission Allowed
+- Permission Allowed
- ❔ Permission Caveated
-- ❌ Permission Denied
+- Permission Denied
- ⚠️ Invalid Check

diff --git a/app/spicedb/ops/performance/page.mdx b/app/spicedb/ops/performance/page.mdx
index 50a83708..919370ac 100644
--- a/app/spicedb/ops/performance/page.mdx
+++ b/app/spicedb/ops/performance/page.mdx
@@ -90,7 +90,7 @@ spicedb serve \
If Materialize is running, SpiceDB can dispatch sub-queries to Materialize, which can significantly speed up permission checks.
-[Materialize]: /authzed/concepts/authzed-materialize
+[Materialize]: /materialize/getting-started/overview
## By enabling the schema cache
@@ -128,7 +128,7 @@ See the [load testing guide](/spicedb/ops/load-testing#spicedb-quantization-perf
For PostgreSQL, CockroachDB, and MySQL datastores, connection pool sizing significantly impacts performance under load.
Key flags include `--datastore-conn-pool-read-max-open`, `--datastore-conn-pool-write-max-open`, and the corresponding min and jitter settings.
-See the [datastores reference](/spicedb/concepts/datastores) for the full list of connection pool flags and defaults, and the [best practices guide](/best-practices#tune-connections-to-datastores) for sizing recommendations.
+See the [datastores reference](/spicedb/concepts/datastores) for the full list of connection pool flags and defaults, and the [best practices guide](/spicedb/best-practices#tune-connections-to-datastores) for sizing recommendations.
## By tuning the transaction overlap strategy (CockroachDB only)
diff --git a/app/spicedb/ops/postgres-fdw/page.mdx b/app/spicedb/ops/postgres-fdw/page.mdx
index ac7b529e..9edb7a3d 100644
--- a/app/spicedb/ops/postgres-fdw/page.mdx
+++ b/app/spicedb/ops/postgres-fdw/page.mdx
@@ -527,7 +527,7 @@ WHERE resource_type = 'document'
### Large Datasets
-For super-fast joins or checks on large datasets, consider [AuthZed Materialize](/authzed/concepts/authzed-materialize).
+For super-fast joins or checks on large datasets, consider [AuthZed Materialize](/materialize/getting-started/overview).
Once set up, Materialize works seamlessly with the FDW with no SQL changes required to your queries.
## Troubleshooting
@@ -576,7 +576,7 @@ If queries are slow:
2. Review your datastore performance (especially important for large datasets)
3. Consider if your queries can be optimized (e.g., using specific resource IDs instead of lookups)
4. Use cursors for large result sets instead of fetching all rows at once
-5. For super-fast performance on large datasets, consider [AuthZed Materialize](/authzed/concepts/authzed-materialize), which works seamlessly with the FDW
+5. For super-fast performance on large datasets, consider [AuthZed Materialize](/materialize/getting-started/overview), which works seamlessly with the FDW
## Security Considerations
diff --git a/components/banner.tsx b/components/banner.tsx
index fa3b7951..675dca1e 100644
--- a/components/banner.tsx
+++ b/components/banner.tsx
@@ -1,19 +1,52 @@
"use client";
import { usePathname } from "next/navigation";
+import { useEffect, useState } from "react";
-export default function Banner() {
+/* Announcement bar — terminal style matching the marketing site
+ (projects/web AnnouncementBar): sand-300 braille cursor, mono copy, a
+ bracketed CTA, and a right-aligned GitHub repo + star. Styling lives in
+ globals.css under `.docs-announce`. The braille cursor runs a short burst on
+ load, then rests; hovering the bar runs it again. */
+export default function Banner({ stars = "5.7k" }: { stars?: string }) {
const pathname = usePathname();
const isCommercial = pathname?.startsWith("/authzed/");
+ const [spin, setSpin] = useState(true);
- return isCommercial ? (
-
- 📄 Have you read Google's Zanzibar paper? We annotated it with additional context and
- comparisons with SpiceDB ↗
-
- ) : (
-
- SpiceDB is 100% open source. Please help us by starring our GitHub repo. ↗
-
+ useEffect(() => {
+ const t = setTimeout(() => setSpin(false), 6000);
+ return () => clearTimeout(t);
+ }, []);
+
+ return (
+
+ {isCommercial ? (
+ <>
+ We annotated Google’s Zanzibar paper with extra context and SpiceDB comparisons.{" "}
+ [
+ Read the annotated paper
+ ]
+ >
+ ) : (
+ <>
+ SpiceDB is 100% open source. [
+ Star us on GitHub
+ ]
+ >
+ )}
+
);
}
diff --git a/components/content-status.css b/components/content-status.css
new file mode 100644
index 00000000..837c7312
--- /dev/null
+++ b/components/content-status.css
@@ -0,0 +1,46 @@
+/* Content review banner — flags pages whose content is new/modified vs
+ origin/main so the team can spot what to review. Theme-adaptive:
+ darker text on light, lighter on dark. */
+.content-status {
+ display: flex;
+ align-items: baseline;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+ margin: 0 0 1.5rem;
+ padding: 0.5rem 0.85rem;
+ border: 1px solid;
+ border-radius: 8px;
+ font-size: 0.85rem;
+ line-height: 1.4;
+}
+.content-status .content-status-dot {
+ font-size: 0.65em;
+}
+.content-status .content-status-label {
+ font-weight: 600;
+}
+.content-status .content-status-note {
+ opacity: 0.7;
+}
+.content-status .content-status-link {
+ margin-left: auto;
+ text-decoration: underline;
+ opacity: 0.85;
+ color: inherit;
+}
+.content-status.is-new {
+ border-color: hsl(146 45% 38% / 0.5);
+ background: hsl(146 45% 45% / 0.12);
+ color: hsl(146 55% 26%);
+}
+.content-status.is-updated {
+ border-color: hsl(38 72% 45% / 0.5);
+ background: hsl(38 72% 50% / 0.12);
+ color: hsl(32 70% 30%);
+}
+.dark .content-status.is-new {
+ color: hsl(146 50% 78%);
+}
+.dark .content-status.is-updated {
+ color: hsl(40 85% 78%);
+}
diff --git a/components/content-status.tsx b/components/content-status.tsx
new file mode 100644
index 00000000..91a688c7
--- /dev/null
+++ b/components/content-status.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import "./content-status.css";
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import changed from "@/lib/changed-pages.json";
+
+type Entry = { status: "new" | "updated" };
+const MANIFEST = changed as Record;
+
+/* Review aid: if the current page's content is new or modified vs the published
+ base (origin/main), show a banner so the team can spot what to review. Driven
+ by lib/changed-pages.json (generated by scripts/build-changed-manifest.mjs).
+ Renders nothing on unchanged pages. */
+export function ContentStatus() {
+ const pathname = usePathname() || "/";
+ const route = pathname.replace(/\/+$/, "") || "/";
+ const entry = MANIFEST[route];
+ if (!entry) return null;
+
+ const isNew = entry.status === "new";
+ return (
+
+
+ ●
+
+ {isNew ? "New page" : "Updated content"}
+ — changed on this branch, for review
+
+ all changes
+
+
+ );
+}
diff --git a/components/cta.tsx b/components/cta.tsx
index 1e383f97..d955a623 100644
--- a/components/cta.tsx
+++ b/components/cta.tsx
@@ -2,22 +2,12 @@
import { Button } from "@/components/ui/button";
import Link from "next/link";
-import { usePathname } from "next/navigation";
+// "Book a demo" now lives in the top nav (app/layout.tsx) so it's persistent
+// rather than docked beside a short page TOC. The rail keeps the Cloud
+// self-serve prompt, which pairs with the Feedback widget below it.
export function TocCTA() {
- const pathname = usePathname();
- const isCommercial = pathname?.startsWith("/authzed/");
-
- return isCommercial ? (
-
+ Relationship-based access control, the Google Zanzibar way: model a{" "}
+ schema, write relationships, and call{" "}
+ CheckPermission from your code. Quickstarts, schema modeling, client
+ SDKs, and full gRPC and HTTP API references — for the open-source SpiceDB engine and
+ the managed AuthZed platform.
+