diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml
index c88db48a08..aaa5d90bcd 100644
--- a/.github/workflows/build-test.yaml
+++ b/.github/workflows/build-test.yaml
@@ -39,7 +39,7 @@ jobs:
       - uses: "actions/checkout@v3"
       - uses: "actions/setup-go@v3"
         with:
-          go-version: "~1.18"
+          go-version: "~1.19"
       - uses: "authzed/actions/go-build@main"
 
   image-build:
@@ -49,7 +49,7 @@ jobs:
       - uses: "actions/checkout@v3"
       - uses: "actions/setup-go@v3"
         with:
-          go-version: "~1.18"
+          go-version: "~1.19"
       - uses: "authzed/actions/docker-build@main"
         with:
           push: false
@@ -68,7 +68,7 @@ jobs:
       - uses: "actions/checkout@v3"
       - uses: "actions/setup-go@v3"
         with:
-          go-version: "~1.18"
+          go-version: "~1.19"
       - uses: "authzed/actions/go-test@main"
         with:
           tags: "ci"
@@ -80,7 +80,7 @@ jobs:
       - uses: "actions/checkout@v3"
       - uses: "actions/setup-go@v3"
         with:
-          go-version: "~1.18"
+          go-version: "~1.19"
       - name: "Cache Binaries"
         id: "cache-binaries"
         uses: "actions/cache@v2"
@@ -128,7 +128,7 @@ jobs:
       - uses: "actions/checkout@v3"
       - uses: "actions/setup-go@v3"
         with:
-          go-version: "~1.18"
+          go-version: "~1.19"
       - name: "Install Go Tools"
         run: "./hack/install-tools.sh"
       - uses: "authzed/actions/buf-generate@main"
diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
index 3c62467535..3c2b0c1edf 100644
--- a/.github/workflows/lint.yaml
+++ b/.github/workflows/lint.yaml
@@ -1,6 +1,6 @@
 ---
 name: "Lint"
-on:  # yamllint disable-line rule:truthy
+on: # yamllint disable-line rule:truthy
   push:
     branches:
       - "!dependabot/*"
@@ -15,7 +15,7 @@ jobs:
       - uses: "actions/checkout@v3"
       - uses: "actions/setup-go@v3"
         with:
-          go-version: "~1.18"
+          go-version: "~1.19"
       - uses: "authzed/actions/go-test@main"
         with:
           working_directory: "tools/analyzers"
@@ -27,7 +27,7 @@ jobs:
       - uses: "actions/checkout@v3"
       - uses: "actions/setup-go@v3"
         with:
-          go-version: "~1.18"
+          go-version: "~1.19"
       - uses: "authzed/actions/gofumpt@main"
       - uses: "authzed/actions/gofumpt@main"
         with:
@@ -48,7 +48,7 @@ jobs:
         with:
           working_directory: "tools/analyzers"
       - name: "Run custom analyzers"
-        run: "./tools/analyzers/analyzers -skip-pkg \"github.com/authzed/spicedb/pkg/proto/dispatch/v1\" -disallowed-nil-return-type-paths \"*github.com/authzed/spicedb/pkg/proto/dispatch/v1.DispatchCheckResponse,*github.com/authzed/spicedb/pkg/proto/dispatch/v1.DispatchExpandResponse,*github.com/authzed/spicedb/pkg/proto/dispatch/v1.DispatchLookupResponse\" ./..."
+        run: './tools/analyzers/analyzers -skip-pkg "github.com/authzed/spicedb/pkg/proto/dispatch/v1" -disallowed-nil-return-type-paths "*github.com/authzed/spicedb/pkg/proto/dispatch/v1.DispatchCheckResponse,*github.com/authzed/spicedb/pkg/proto/dispatch/v1.DispatchExpandResponse,*github.com/authzed/spicedb/pkg/proto/dispatch/v1.DispatchLookupResponse" ./...'
 
   extra-lint:
     name: "Lint YAML & Markdown"
@@ -92,4 +92,4 @@ jobs:
       - name: "Upload Trivy scan results to GitHub Security tab"
         uses: "github/codeql-action/upload-sarif@v2"
         with:
-          sarif_file: 'trivy-results.sarif'
+          sarif_file: "trivy-results.sarif"
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 07a7094787..47cf390909 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -1,6 +1,6 @@
 ---
 name: "Release"
-on:  # yamllint disable-line rule:truthy
+on: # yamllint disable-line rule:truthy
   push:
     tags:
       - "*"
@@ -16,7 +16,7 @@ jobs:
           fetch-depth: 0
       - uses: "actions/setup-go@v3"
         with:
-          go-version: "~1.18"
+          go-version: "~1.19"
       - uses: "authzed/actions/docker-login@main"
         with:
           quayio_token: "${{ secrets.QUAYIO_PASSWORD }}"
diff --git a/Dockerfile b/Dockerfile
index 563137b8c1..b5bde0ae52 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.18-alpine3.15 AS spicedb-builder
+FROM golang:1.19-alpine3.16 AS spicedb-builder
 WORKDIR /go/src/app
 RUN apk update && apk add --no-cache git
 COPY . .
diff --git a/e2e/go.mod b/e2e/go.mod
index 0dabf13901..08bb14b86c 100644
--- a/e2e/go.mod
+++ b/e2e/go.mod
@@ -3,7 +3,7 @@ module github.com/authzed/spicedb/e2e
 go 1.18
 
 require (
-	github.com/authzed/authzed-go v0.6.1-0.20220721164311-7b705b328aed
+	github.com/authzed/authzed-go v0.6.1-0.20220829195957-23aec9014d2f
 	github.com/authzed/grpcutil v0.0.0-20220104222419-f813f77722e5
 	github.com/authzed/spicedb v1.5.0
 	github.com/brianvoe/gofakeit/v6 v6.15.0
@@ -25,7 +25,7 @@ require (
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/google/go-cmp v0.5.8 // indirect
 	github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
-	github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.1 // indirect
+	github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.2 // indirect
 	github.com/jackc/chunkreader/v2 v2.0.1 // indirect
 	github.com/jackc/pgconn v1.12.1 // indirect
 	github.com/jackc/pgio v1.0.0 // indirect
@@ -36,11 +36,11 @@ require (
 	github.com/shopspring/decimal v1.3.1 // indirect
 	golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f // indirect
 	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
-	golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
+	golang.org/x/net v0.0.0-20220812174116-3211cb980234 // indirect
 	golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
-	golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
+	golang.org/x/sys v0.0.0-20220818161305-2296e01440c6 // indirect
 	golang.org/x/text v0.3.7 // indirect
-	google.golang.org/genproto v0.0.0-20220728213248-dd149ef739b9 // indirect
+	google.golang.org/genproto v0.0.0-20220819174105-e9f053255caa // indirect
 	google.golang.org/protobuf v1.28.1 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/e2e/go.sum b/e2e/go.sum
index cf079395eb..d8b688238e 100644
--- a/e2e/go.sum
+++ b/e2e/go.sum
@@ -6,8 +6,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
 github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
-github.com/authzed/authzed-go v0.6.1-0.20220721164311-7b705b328aed h1:FX1+vz+Z/eOMeGUmz4XoDinFhrx4R74tKG8z29Var8o=
-github.com/authzed/authzed-go v0.6.1-0.20220721164311-7b705b328aed/go.mod h1:q211JgPx8zRvU4IkwrdCh810PfDTr5eWpvbtD661AdE=
+github.com/authzed/authzed-go v0.6.1-0.20220829195957-23aec9014d2f h1:hmtxtXGcUCxGuA3bJGTy0qK+18tUlVvVFMvyauebW34=
+github.com/authzed/authzed-go v0.6.1-0.20220829195957-23aec9014d2f/go.mod h1:m4JRMHkPM/pVqcvrON8wkYGIBILqkbMno1UNL62rXN0=
 github.com/authzed/grpcutil v0.0.0-20220104222419-f813f77722e5 h1:sZM7XzdyuLyxj7pC/g7uX+XAqZ7m6NMxZzuQRovgBPw=
 github.com/authzed/grpcutil v0.0.0-20220104222419-f813f77722e5/go.mod h1:rqjY3zyK/YP7NID9+B2BdIRRkvnK+cdf9/qya/zaFZE=
 github.com/brianvoe/gofakeit/v6 v6.15.0 h1:lJPGJZ2/07TRGDazyTzD5b18N3y4tmmJpdhCUw18FlI=
@@ -97,8 +97,8 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+
 github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw=
 github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y=
 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.1 h1:/sDbPb60SusIXjiJGYLUoS/rAQurQmvGWmwn2bBPM9c=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.1/go.mod h1:G+WkljZi4mflcqVxYSgvt8MNctRQHjEH8ubKtt1Ka3w=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.2 h1:BqHID5W5qnMkug0Z8UmL8tN0gAy4jQ+B4WFt8cCgluU=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.2/go.mod h1:ZbS3MZTZq/apAfAEHGoB5HbsQQstoqP92SjAqtQ9zeg=
 github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
 github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
 github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
@@ -268,8 +268,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
 golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20220812174116-3211cb980234 h1:RDqmgfe7SvlMWoqC3xwQ2blLO3fcWcxMa3eBLRdRW7E=
+golang.org/x/net v0.0.0-20220812174116-3211cb980234/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c h1:q3gFqPqH7NVofKo3c3yETAP//pPI+G5mvB7qqj1Y5kY=
@@ -304,8 +304,8 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210816183151-1e6c022a8912/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220818161305-2296e01440c6 h1:Sx/u41w+OwrInGdEckYmEuU5gHoGSL4QbDz3S9s6j4U=
+golang.org/x/sys v0.0.0-20220818161305-2296e01440c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -349,8 +349,8 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
 google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
 google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto v0.0.0-20220728213248-dd149ef739b9 h1:d3fKQZK+1rWQMg3xLKQbPMirUCo29I/NRdI2WarSzTg=
-google.golang.org/genproto v0.0.0-20220728213248-dd149ef739b9/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc=
+google.golang.org/genproto v0.0.0-20220819174105-e9f053255caa h1:Ux9yJCyf598uEniFPSyp8g1jtGTt77m+lzYyVgrWQaQ=
+google.golang.org/genproto v0.0.0-20220819174105-e9f053255caa/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
 google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
diff --git a/go.mod b/go.mod
index e43f66411e..f1f138e43e 100644
--- a/go.mod
+++ b/go.mod
@@ -1,11 +1,11 @@
 module github.com/authzed/spicedb
 
-go 1.18
+go 1.19
 
 require (
 	cloud.google.com/go/spanner v1.36.0
 	github.com/Masterminds/squirrel v1.5.3
-	github.com/authzed/authzed-go v0.6.1-0.20220721164311-7b705b328aed
+	github.com/authzed/authzed-go v0.6.1-0.20220829195957-23aec9014d2f
 	github.com/authzed/grpcutil v0.0.0-20220104222419-f813f77722e5
 	github.com/aws/aws-sdk-go v1.44.67
 	github.com/benbjohnson/clock v1.3.0
@@ -30,7 +30,7 @@ require (
 	github.com/grpc-ecosystem/go-grpc-middleware/providers/zerolog/v2 v2.0.0-rc.2.0.20210831071041-dd1540ef8252
 	github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.0-rc.2.0.20210831071041-dd1540ef8252
 	github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0
-	github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.1
+	github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.2
 	github.com/hashicorp/go-memdb v1.3.3
 	github.com/influxdata/tdigest v0.0.1
 	github.com/jackc/pgconn v1.12.1
@@ -60,11 +60,12 @@ require (
 	go.opentelemetry.io/otel v1.8.0
 	go.opentelemetry.io/otel/trace v1.8.0
 	go.uber.org/goleak v1.1.12
+	golang.org/x/exp v0.0.0-20220823124025-807a23277127
 	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4
 	golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4
 	golang.org/x/tools v0.1.12
 	google.golang.org/api v0.90.0
-	google.golang.org/genproto v0.0.0-20220728213248-dd149ef739b9
+	google.golang.org/genproto v0.0.0-20220819174105-e9f053255caa
 	google.golang.org/grpc v1.48.0
 	google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.2.0
 	google.golang.org/protobuf v1.28.1
@@ -122,7 +123,7 @@ require (
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
 	github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
-	github.com/lyft/protoc-gen-star v0.6.0 // indirect
+	github.com/lyft/protoc-gen-star v0.6.1 // indirect
 	github.com/magiconair/properties v1.8.6 // indirect
 	github.com/mattn/go-colorable v0.1.12 // indirect
 	github.com/mattn/go-isatty v0.0.14 // indirect
@@ -143,7 +144,7 @@ require (
 	github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
 	github.com/shabbyrobe/gocovmerge v0.0.0-20190829150210-3e036491d500 // indirect
 	github.com/sirupsen/logrus v1.8.1 // indirect
-	github.com/spf13/afero v1.8.2 // indirect
+	github.com/spf13/afero v1.9.2 // indirect
 	github.com/spf13/cast v1.4.1 // indirect
 	github.com/spf13/jwalterweatherman v1.1.0 // indirect
 	github.com/spf13/viper v1.11.0 // indirect
@@ -168,9 +169,9 @@ require (
 	go.uber.org/multierr v1.8.0 // indirect
 	golang.org/x/crypto v0.0.0-20220427172511-eb4f295cb31f // indirect
 	golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect
-	golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
+	golang.org/x/net v0.0.0-20220812174116-3211cb980234 // indirect
 	golang.org/x/oauth2 v0.0.0-20220722155238-128564f6959c // indirect
-	golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
+	golang.org/x/sys v0.0.0-20220818161305-2296e01440c6 // indirect
 	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 // indirect
diff --git a/go.sum b/go.sum
index 7d1bf1aa5e..ef7205bee1 100644
--- a/go.sum
+++ b/go.sum
@@ -95,8 +95,8 @@ github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk5
 github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
-github.com/authzed/authzed-go v0.6.1-0.20220721164311-7b705b328aed h1:FX1+vz+Z/eOMeGUmz4XoDinFhrx4R74tKG8z29Var8o=
-github.com/authzed/authzed-go v0.6.1-0.20220721164311-7b705b328aed/go.mod h1:q211JgPx8zRvU4IkwrdCh810PfDTr5eWpvbtD661AdE=
+github.com/authzed/authzed-go v0.6.1-0.20220829195957-23aec9014d2f h1:hmtxtXGcUCxGuA3bJGTy0qK+18tUlVvVFMvyauebW34=
+github.com/authzed/authzed-go v0.6.1-0.20220829195957-23aec9014d2f/go.mod h1:m4JRMHkPM/pVqcvrON8wkYGIBILqkbMno1UNL62rXN0=
 github.com/authzed/grpcutil v0.0.0-20220104222419-f813f77722e5 h1:sZM7XzdyuLyxj7pC/g7uX+XAqZ7m6NMxZzuQRovgBPw=
 github.com/authzed/grpcutil v0.0.0-20220104222419-f813f77722e5/go.mod h1:rqjY3zyK/YP7NID9+B2BdIRRkvnK+cdf9/qya/zaFZE=
 github.com/aws/aws-sdk-go v1.17.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
@@ -374,8 +374,8 @@ github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92Bcuy
 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
 github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
 github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.1 h1:/sDbPb60SusIXjiJGYLUoS/rAQurQmvGWmwn2bBPM9c=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.1/go.mod h1:G+WkljZi4mflcqVxYSgvt8MNctRQHjEH8ubKtt1Ka3w=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.2 h1:BqHID5W5qnMkug0Z8UmL8tN0gAy4jQ+B4WFt8cCgluU=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.11.2/go.mod h1:ZbS3MZTZq/apAfAEHGoB5HbsQQstoqP92SjAqtQ9zeg=
 github.com/hashicorp/go-immutable-radix v1.3.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
 github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
 github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
@@ -505,8 +505,9 @@ github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
 github.com/lib/pq v1.10.6 h1:jbk+ZieJ0D7EVGJYpL9QTz7/YW6UHbmdnZWYyK5cdBs=
 github.com/lib/pq v1.10.6/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
-github.com/lyft/protoc-gen-star v0.6.0 h1:xOpFu4vwmIoUeUrRuAtdCrZZymT/6AkW/bsUWA506Fo=
 github.com/lyft/protoc-gen-star v0.6.0/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
+github.com/lyft/protoc-gen-star v0.6.1 h1:erE0rdztuaDq3bpGifD95wfoPrSZc95nGA6tbiNYh6M=
+github.com/lyft/protoc-gen-star v0.6.1/go.mod h1:TGAoBVkt8w7MPG72TrKIu85MIdXwDuzJYeZuUPFPNwA=
 github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo=
 github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -661,8 +662,8 @@ github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTd
 github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
 github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4=
 github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
-github.com/spf13/afero v1.8.2 h1:xehSyVa0YnHWsJ49JFljMpg1HX19V6NDZ1fkm1Xznbo=
-github.com/spf13/afero v1.8.2/go.mod h1:CtAatgMJh6bJEIs48Ay/FOnkljP3WeGUG0MC1RfAqwo=
+github.com/spf13/afero v1.9.2 h1:j49Hj62F0n+DaZ1dDCvhABaPNSGNkt32oRFxI33IEMw=
+github.com/spf13/afero v1.9.2/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y=
 github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA=
 github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
@@ -798,8 +799,9 @@ golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u0
 golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
 golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
-golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6 h1:QE6XYQK6naiK1EPAe1g/ILLxN5RBoH5xkJk3CqlMI/Y=
 golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
+golang.org/x/exp v0.0.0-20220823124025-807a23277127 h1:S4NrSKDfihhl3+4jSTgwoIevKxX9p7Iv9x++OEIptDo=
+golang.org/x/exp v0.0.0-20220823124025-807a23277127/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
 golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -883,8 +885,8 @@ golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su
 golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0=
-golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.0.0-20220812174116-3211cb980234 h1:RDqmgfe7SvlMWoqC3xwQ2blLO3fcWcxMa3eBLRdRW7E=
+golang.org/x/net v0.0.0-20220812174116-3211cb980234/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1011,8 +1013,8 @@ golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
-golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220818161305-2296e01440c6 h1:Sx/u41w+OwrInGdEckYmEuU5gHoGSL4QbDz3S9s6j4U=
+golang.org/x/sys v0.0.0-20220818161305-2296e01440c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
@@ -1249,8 +1251,8 @@ google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljW
 google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
 google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
-google.golang.org/genproto v0.0.0-20220728213248-dd149ef739b9 h1:d3fKQZK+1rWQMg3xLKQbPMirUCo29I/NRdI2WarSzTg=
-google.golang.org/genproto v0.0.0-20220728213248-dd149ef739b9/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc=
+google.golang.org/genproto v0.0.0-20220819174105-e9f053255caa h1:Ux9yJCyf598uEniFPSyp8g1jtGTt77m+lzYyVgrWQaQ=
+google.golang.org/genproto v0.0.0-20220819174105-e9f053255caa/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
diff --git a/internal/dispatch/caching/caching.go b/internal/dispatch/caching/caching.go
index 5e78fdcbc7..7eb6f45178 100644
--- a/internal/dispatch/caching/caching.go
+++ b/internal/dispatch/caching/caching.go
@@ -432,8 +432,11 @@ func (cd *Dispatcher) DispatchLookupSubjects(req *v1.DispatchLookupSubjectsReque
 			mu.Lock()
 			defer mu.Unlock()
 
-			for _, id := range result.FoundSubjectIds {
-				estimatedSize += int64(len(id))
+			for _, found := range result.FoundSubjects {
+				estimatedSize += int64(len(found.SubjectId))
+				for _, excludedID := range found.ExcludedSubjectIds {
+					estimatedSize += int64(len(excludedID))
+				}
 			}
 
 			toCacheResults = append(toCacheResults, adjustedResult)
diff --git a/internal/dispatch/dispatch.go b/internal/dispatch/dispatch.go
index 7bd0f27ae5..10f8c211ce 100644
--- a/internal/dispatch/dispatch.go
+++ b/internal/dispatch/dispatch.go
@@ -166,3 +166,18 @@ func LookupSubjectsRequestToKey(req *v1.DispatchLookupSubjectsRequest) string {
 		req.Metadata.AtRevision,
 	)
 }
+
+// AddResponseMetadata adds the metadata found in the incoming metadata to the existing
+// metadata, *modifying it in place*.
+func AddResponseMetadata(existing *v1.ResponseMeta, incoming *v1.ResponseMeta) {
+	existing.DispatchCount += incoming.DispatchCount
+	existing.CachedDispatchCount += incoming.CachedDispatchCount
+	existing.DepthRequired = max(existing.DepthRequired, incoming.DepthRequired)
+}
+
+func max(x, y uint32) uint32 {
+	if x < y {
+		return y
+	}
+	return x
+}
diff --git a/internal/dispatch/graph/check_test.go b/internal/dispatch/graph/check_test.go
index f5f677858a..b891df9bf0 100644
--- a/internal/dispatch/graph/check_test.go
+++ b/internal/dispatch/graph/check_test.go
@@ -196,7 +196,7 @@ func TestCheckMetadata(t *testing.T) {
 				[]expected{
 					{"owner", true, 1, 1},
 					{"edit", true, 3, 2},
-					{"view", true, 21, 3},
+					{"view", true, 21, 5},
 				},
 			},
 			{
diff --git a/internal/dispatch/graph/lookupsubjects_test.go b/internal/dispatch/graph/lookupsubjects_test.go
index 66a7523eb9..58d12067fa 100644
--- a/internal/dispatch/graph/lookupsubjects_test.go
+++ b/internal/dispatch/graph/lookupsubjects_test.go
@@ -140,7 +140,13 @@ func TestSimpleLookupSubjects(t *testing.T) {
 
 			foundSubjectIds := []string{}
 			for _, result := range stream.Results() {
-				foundSubjectIds = append(foundSubjectIds, result.FoundSubjectIds...)
+				for _, found := range result.FoundSubjects {
+					if len(found.ExcludedSubjectIds) > 0 {
+						continue
+					}
+
+					foundSubjectIds = append(foundSubjectIds, found.SubjectId)
+				}
 			}
 
 			sort.Strings(foundSubjectIds)
@@ -211,7 +217,7 @@ func TestLookupSubjectsDispatchCount(t *testing.T) {
 			"view",
 			"user",
 			"...",
-			4,
+			13,
 		},
 		{
 			"document",
diff --git a/internal/dispatch/stream.go b/internal/dispatch/stream.go
index ebb4305fb3..af02003e26 100644
--- a/internal/dispatch/stream.go
+++ b/internal/dispatch/stream.go
@@ -114,6 +114,38 @@ func StreamWithContext[T any](context context.Context, stream Stream[T]) Stream[
 	}
 }
 
+// HandlingDispatchStream is a dispatch stream that executes a handler for each item published.
+// It uses an internal mutex to ensure it is thread safe.
+type HandlingDispatchStream[T any] struct {
+	ctx       context.Context
+	processor func(result T) error
+	mu        sync.Mutex
+}
+
+// NewHandlingDispatchStream returns a new handling dispatch stream.
+func NewHandlingDispatchStream[T any](ctx context.Context, processor func(result T) error) Stream[T] {
+	return &HandlingDispatchStream[T]{
+		ctx:       ctx,
+		processor: processor,
+		mu:        sync.Mutex{},
+	}
+}
+
+func (s *HandlingDispatchStream[T]) Publish(result T) error {
+	s.mu.Lock()
+	defer s.mu.Unlock()
+
+	if s.processor == nil {
+		return nil
+	}
+
+	return s.processor(result)
+}
+
+func (s *HandlingDispatchStream[T]) Context() context.Context {
+	return s.ctx
+}
+
 // Ensure the streams implement the interface.
 var _ Stream[any] = &CollectingDispatchStream[any]{}
 var _ Stream[any] = &WrappedDispatchStream[any]{}
diff --git a/internal/graph/lookupsubjects.go b/internal/graph/lookupsubjects.go
index 20aa8e3345..ad505d72af 100644
--- a/internal/graph/lookupsubjects.go
+++ b/internal/graph/lookupsubjects.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"errors"
 	"fmt"
-	"sync"
 
 	"github.com/rs/zerolog/log"
 	"github.com/shopspring/decimal"
@@ -51,8 +50,8 @@ func (cl *ConcurrentLookupSubjects) LookupSubjects(
 	if req.SubjectRelation.Namespace == req.ResourceRelation.Namespace &&
 		req.SubjectRelation.Relation == req.ResourceRelation.Relation {
 		err := stream.Publish(&v1.DispatchLookupSubjectsResponse{
-			FoundSubjectIds: req.ResourceIds,
-			Metadata:        emptyMetadata,
+			FoundSubjects: subjectsForIds(req.ResourceIds),
+			Metadata:      emptyMetadata,
 		})
 		if err != nil {
 			return err
@@ -78,6 +77,16 @@ func (cl *ConcurrentLookupSubjects) LookupSubjects(
 	return cl.lookupViaRewrite(ctx, req, stream, relation.UsersetRewrite)
 }
 
+func subjectsForIds(subjectIds []string) []*v1.FoundSubject {
+	foundSubjects := make([]*v1.FoundSubject, 0, len(subjectIds))
+	for _, subjectID := range subjectIds {
+		foundSubjects = append(foundSubjects, &v1.FoundSubject{
+			SubjectId: subjectID,
+		})
+	}
+	return foundSubjects
+}
+
 func (cl *ConcurrentLookupSubjects) lookupDirectSubjects(
 	ctx context.Context,
 	req ValidatedLookupSubjectsRequest,
@@ -115,8 +124,8 @@ func (cl *ConcurrentLookupSubjects) lookupDirectSubjects(
 
 	if len(foundSubjectIds) > 0 {
 		err := stream.Publish(&v1.DispatchLookupSubjectsResponse{
-			FoundSubjectIds: foundSubjectIds,
-			Metadata:        emptyMetadata,
+			FoundSubjects: subjectsForIds(foundSubjectIds),
+			Metadata:      emptyMetadata,
 		})
 		if err != nil {
 			return err
@@ -147,8 +156,8 @@ func (cl *ConcurrentLookupSubjects) lookupViaComputed(
 		Ctx:    ctx,
 		Processor: func(result *v1.DispatchLookupSubjectsResponse) (*v1.DispatchLookupSubjectsResponse, bool, error) {
 			return &v1.DispatchLookupSubjectsResponse{
-				FoundSubjectIds: result.FoundSubjectIds,
-				Metadata:        addCallToResponseMetadata(result.Metadata),
+				FoundSubjects: result.FoundSubjects,
+				Metadata:      addCallToResponseMetadata(result.Metadata),
 			}, true, nil
 		},
 	}
@@ -309,8 +318,8 @@ func (cl *ConcurrentLookupSubjects) dispatchTo(
 		Ctx:    subCtx,
 		Processor: func(result *v1.DispatchLookupSubjectsResponse) (*v1.DispatchLookupSubjectsResponse, bool, error) {
 			return &v1.DispatchLookupSubjectsResponse{
-				FoundSubjectIds: result.FoundSubjectIds,
-				Metadata:        addCallToResponseMetadata(result.Metadata),
+				FoundSubjects: result.FoundSubjects,
+				Metadata:      addCallToResponseMetadata(result.Metadata),
 			}, true, nil
 		},
 	}
@@ -342,47 +351,46 @@ type lookupSubjectsReducer interface {
 // Union
 type lookupSubjectsUnion struct {
 	parentStream dispatch.LookupSubjectsStream
-	encountered  *util.Set[string]
-	mu           sync.Mutex
+	collectors   map[int]*dispatch.CollectingDispatchStream[*v1.DispatchLookupSubjectsResponse]
 }
 
 func newLookupSubjectsUnion(parentStream dispatch.LookupSubjectsStream) *lookupSubjectsUnion {
 	return &lookupSubjectsUnion{
 		parentStream: parentStream,
-		encountered:  util.NewSet[string](),
-		mu:           sync.Mutex{},
+		collectors:   map[int]*dispatch.CollectingDispatchStream[*v1.DispatchLookupSubjectsResponse]{},
 	}
 }
 
 func (lsu *lookupSubjectsUnion) ForIndex(ctx context.Context, setOperationIndex int) dispatch.LookupSubjectsStream {
-	return &dispatch.WrappedDispatchStream[*v1.DispatchLookupSubjectsResponse]{
-		Stream: lsu.parentStream,
-		Ctx:    ctx,
-		Processor: func(result *v1.DispatchLookupSubjectsResponse) (*v1.DispatchLookupSubjectsResponse, bool, error) {
-			lsu.mu.Lock()
-			defer lsu.mu.Unlock()
-
-			filtered := make([]string, 0, len(result.FoundSubjectIds))
-			for _, subjectID := range result.FoundSubjectIds {
-				if lsu.encountered.Add(subjectID) {
-					filtered = append(filtered, subjectID)
-				}
-			}
+	collector := dispatch.NewCollectingDispatchStream[*v1.DispatchLookupSubjectsResponse](ctx)
+	lsu.collectors[setOperationIndex] = collector
+	return collector
+}
 
-			if len(filtered) == 0 {
-				return nil, false, nil
-			}
+func (lsu *lookupSubjectsUnion) CompletedChildOperations() error {
+	foundSubjects := util.NewSubjectSet()
+	metadata := emptyMetadata
 
-			return &v1.DispatchLookupSubjectsResponse{
-				FoundSubjectIds: filtered,
-				Metadata:        result.Metadata,
-			}, true, nil
-		},
+	for index := 0; index < len(lsu.collectors); index++ {
+		collector, ok := lsu.collectors[index]
+		if !ok {
+			return fmt.Errorf("missing collector for index %d", index)
+		}
+
+		for _, result := range collector.Results() {
+			metadata = combineResponseMetadata(metadata, result.Metadata)
+			foundSubjects.UnionWith(result.FoundSubjects)
+		}
 	}
-}
 
-func (lsu *lookupSubjectsUnion) CompletedChildOperations() error {
-	return nil
+	if foundSubjects.IsEmpty() {
+		return nil
+	}
+
+	return lsu.parentStream.Publish(&v1.DispatchLookupSubjectsResponse{
+		FoundSubjects: foundSubjects.AsSlice(),
+		Metadata:      metadata,
+	})
 }
 
 // Intersection
@@ -405,7 +413,7 @@ func (lsi *lookupSubjectsIntersection) ForIndex(ctx context.Context, setOperatio
 }
 
 func (lsi *lookupSubjectsIntersection) CompletedChildOperations() error {
-	var foundSubjectIds *util.Set[string]
+	var foundSubjects util.SubjectSet
 	metadata := emptyMetadata
 
 	for index := 0; index < len(lsi.collectors); index++ {
@@ -414,25 +422,25 @@ func (lsi *lookupSubjectsIntersection) CompletedChildOperations() error {
 			return fmt.Errorf("missing collector for index %d", index)
 		}
 
-		results := util.NewSet[string]()
+		results := util.NewSubjectSet()
 		for _, result := range collector.Results() {
 			metadata = combineResponseMetadata(metadata, result.Metadata)
-			results.Extend(result.FoundSubjectIds)
+			results.UnionWith(result.FoundSubjects)
 		}
 
 		if index == 0 {
-			foundSubjectIds = results
+			foundSubjects = results
 		} else {
-			foundSubjectIds.IntersectionDifference(results)
-			if foundSubjectIds.IsEmpty() {
+			foundSubjects.IntersectionDifference(results)
+			if foundSubjects.IsEmpty() {
 				return nil
 			}
 		}
 	}
 
 	return lsi.parentStream.Publish(&v1.DispatchLookupSubjectsResponse{
-		FoundSubjectIds: foundSubjectIds.AsSlice(),
-		Metadata:        metadata,
+		FoundSubjects: foundSubjects.AsSlice(),
+		Metadata:      metadata,
 	})
 }
 
@@ -456,29 +464,29 @@ func (lse *lookupSubjectsExclusion) ForIndex(ctx context.Context, setOperationIn
 }
 
 func (lse *lookupSubjectsExclusion) CompletedChildOperations() error {
-	var foundSubjectIds *util.Set[string]
+	var foundSubjects util.SubjectSet
 	metadata := emptyMetadata
 
 	for index := 0; index < len(lse.collectors); index++ {
 		collector := lse.collectors[index]
-		results := util.NewSet[string]()
+		results := util.NewSubjectSet()
 		for _, result := range collector.Results() {
 			metadata = combineResponseMetadata(metadata, result.Metadata)
-			results.Extend(result.FoundSubjectIds)
+			results.UnionWith(result.FoundSubjects)
 		}
 
 		if index == 0 {
-			foundSubjectIds = results
+			foundSubjects = results
 		} else {
-			foundSubjectIds.RemoveAll(results)
-			if foundSubjectIds.IsEmpty() {
+			foundSubjects.SubtractAll(results)
+			if foundSubjects.IsEmpty() {
 				return nil
 			}
 		}
 	}
 
 	return lse.parentStream.Publish(&v1.DispatchLookupSubjectsResponse{
-		FoundSubjectIds: foundSubjectIds.AsSlice(),
-		Metadata:        metadata,
+		FoundSubjects: foundSubjects.AsSlice(),
+		Metadata:      metadata,
 	})
 }
diff --git a/internal/membership/foundsubject.go b/internal/membership/foundsubject.go
index d9684202f8..fbb3a03d2b 100644
--- a/internal/membership/foundsubject.go
+++ b/internal/membership/foundsubject.go
@@ -12,7 +12,7 @@ import (
 
 // NewFoundSubject creates a new FoundSubject for a subject and a set of its resources.
 func NewFoundSubject(subject *core.ObjectAndRelation, resources ...*core.ObjectAndRelation) FoundSubject {
-	return FoundSubject{subject, tuple.NewONRSet(), tuple.NewONRSet(resources...)}
+	return FoundSubject{subject, nil, tuple.NewONRSet(resources...)}
 }
 
 // FoundSubject contains a single found subject and all the relationships in which that subject
@@ -22,13 +22,24 @@ type FoundSubject struct {
 	subject *core.ObjectAndRelation
 
 	// excludedSubjects are any subjects excluded. Only should be set if subject is a wildcard.
-	excludedSubjects *tuple.ONRSet
+	excludedSubjectIds []string
 
 	// relations are the relations under which the subject lives that informed the locating
 	// of this subject for the root ONR.
 	relationships *tuple.ONRSet
 }
 
+// GetSubjectId is named to match the Subject interface for the BaseSubjectSet.
+//
+//nolint:all
+func (fs FoundSubject) GetSubjectId() string {
+	return fs.subject.ObjectId
+}
+
+func (fs FoundSubject) GetExcludedSubjectIds() []string {
+	return fs.excludedSubjectIds
+}
+
 // Subject returns the Subject of the FoundSubject.
 func (fs FoundSubject) Subject() *core.ObjectAndRelation {
 	return fs.subject
@@ -47,7 +58,16 @@ func (fs FoundSubject) WildcardType() (string, bool) {
 // If not a wildcard subject, returns false.
 func (fs FoundSubject) ExcludedSubjectsFromWildcard() ([]*core.ObjectAndRelation, bool) {
 	if fs.subject.ObjectId == tuple.PublicWildcard {
-		return fs.excludedSubjects.AsSlice(), true
+		excludedSubjects := make([]*core.ObjectAndRelation, 0, len(fs.excludedSubjectIds))
+		for _, excludedID := range fs.excludedSubjectIds {
+			excludedSubjects = append(excludedSubjects, &core.ObjectAndRelation{
+				Namespace: fs.subject.Namespace,
+				ObjectId:  excludedID,
+				Relation:  fs.subject.Relation,
+			})
+		}
+
+		return excludedSubjects, true
 	}
 
 	return []*core.ObjectAndRelation{}, false
@@ -76,67 +96,18 @@ func (fs FoundSubject) ToValidationString() string {
 	return onrString
 }
 
-// union performs merging of two FoundSubject's with the same subject.
-func (fs FoundSubject) union(other FoundSubject) FoundSubject {
-	if toKey(fs.subject) != toKey(other.subject) {
-		panic("Got wrong found subject to union")
-	}
-
-	relationships := fs.relationships.Union(other.relationships)
-	var excludedSubjects *tuple.ONRSet
-
-	// If a wildcard, then union together excluded subjects.
-	_, isWildcard := fs.WildcardType()
-	if isWildcard {
-		excludedSubjects = fs.excludedSubjects.Union(other.excludedSubjects)
-	}
-
-	return FoundSubject{
-		subject:          fs.subject,
-		excludedSubjects: excludedSubjects,
-		relationships:    relationships,
-	}
-}
-
-// intersect performs intersection between two FoundSubject's with the same subject.
-func (fs FoundSubject) intersect(other FoundSubject) FoundSubject {
-	if toKey(fs.subject) != toKey(other.subject) {
-		panic("Got wrong found subject to intersect")
-	}
-
-	relationships := fs.relationships.Union(other.relationships)
-	var excludedSubjects *tuple.ONRSet
-
-	// If a wildcard, then union together excluded subjects.
-	_, isWildcard := fs.WildcardType()
-	if isWildcard {
-		excludedSubjects = fs.excludedSubjects.Union(other.excludedSubjects)
-	}
-
-	return FoundSubject{
-		subject:          fs.subject,
-		excludedSubjects: excludedSubjects,
-		relationships:    relationships,
-	}
-}
-
 // FoundSubjects contains the subjects found for a specific ONR.
 type FoundSubjects struct {
 	// subjects is a map from the Subject ONR (as a string) to the FoundSubject information.
-	subjects map[string]FoundSubject
+	subjects TrackingSubjectSet
 }
 
 // ListFound returns a slice of all the FoundSubject's.
 func (fs FoundSubjects) ListFound() []FoundSubject {
-	found := []FoundSubject{}
-	for _, sub := range fs.subjects {
-		found = append(found, sub)
-	}
-	return found
+	return fs.subjects.ToSlice()
 }
 
 // LookupSubject returns the FoundSubject for a matching subject, if any.
 func (fs FoundSubjects) LookupSubject(subject *core.ObjectAndRelation) (FoundSubject, bool) {
-	found, ok := fs.subjects[toKey(subject)]
-	return found, ok
+	return fs.subjects.Get(subject)
 }
diff --git a/internal/membership/foundsubject_test.go b/internal/membership/foundsubject_test.go
index 8334af9442..b573b2cab9 100644
--- a/internal/membership/foundsubject_test.go
+++ b/internal/membership/foundsubject_test.go
@@ -22,29 +22,20 @@ func TestToValidationString(t *testing.T) {
 		},
 		{
 			"with exclusion",
-			fs("user", "*", "...", ONR("user", "user1", "...")),
+			fs("user", "*", "...", "user1"),
 			"user:* - {user:user1}",
 		},
 		{
 			"with some exclusion",
 			fs("user", "*", "...",
-				ONR("user", "user1", "..."),
-				ONR("user", "user2", "..."),
-				ONR("user", "user3", "..."),
-				ONR("user", "user4", "..."),
-				ONR("user", "user5", "..."),
+				"user1", "user2", "user3", "user4", "user5",
 			),
 			"user:* - {user:user1, user:user2, user:user3, user:user4, user:user5}",
 		},
 		{
 			"with many exclusion",
 			fs("user", "*", "...",
-				ONR("user", "user1", "..."),
-				ONR("user", "user2", "..."),
-				ONR("user", "user3", "..."),
-				ONR("user", "user4", "..."),
-				ONR("user", "user5", "..."),
-				ONR("user", "user6", "..."),
+				"user1", "user2", "user3", "user4", "user5", "user6",
 			),
 			"user:* - {user:user1, user:user2, user:user3, user:user4, user:user5, user:user6}",
 		},
diff --git a/internal/membership/membership.go b/internal/membership/membership.go
index d9f86a6c9c..5de467dc1d 100644
--- a/internal/membership/membership.go
+++ b/internal/membership/membership.go
@@ -51,11 +51,11 @@ func (ms *Set) AddExpansion(onr *core.ObjectAndRelation, expansion *core.Relatio
 }
 
 // AccessibleExpansionSubjects returns a TrackingSubjectSet representing the set of accessible subjects in the expansion.
-func AccessibleExpansionSubjects(treeNode *core.RelationTupleTreeNode) (TrackingSubjectSet, error) {
+func AccessibleExpansionSubjects(treeNode *core.RelationTupleTreeNode) (*TrackingSubjectSet, error) {
 	return populateFoundSubjects(treeNode.Expanded, treeNode)
 }
 
-func populateFoundSubjects(rootONR *core.ObjectAndRelation, treeNode *core.RelationTupleTreeNode) (TrackingSubjectSet, error) {
+func populateFoundSubjects(rootONR *core.ObjectAndRelation, treeNode *core.RelationTupleTreeNode) (*TrackingSubjectSet, error) {
 	resource := rootONR
 	if treeNode.Expanded != nil {
 		resource = treeNode.Expanded
diff --git a/internal/membership/membership_test.go b/internal/membership/membership_test.go
index acdb41f0af..fdb860e59e 100644
--- a/internal/membership/membership_test.go
+++ b/internal/membership/membership_test.go
@@ -98,6 +98,71 @@ func TestMembershipSetIntersectionBasic(t *testing.T) {
 	verifySubjects(t, require, fso, "user:legal")
 }
 
+func TestMembershipSetIntersectionWithDifferentTypesOneMissingLeft(t *testing.T) {
+	require := require.New(t)
+	ms := NewMembershipSet()
+
+	intersection := graph.Intersection(ONR("folder", "company", "viewer"),
+		graph.Leaf(_this,
+			(ONR("user", "legal", "...")),
+			(ONR("folder", "foobar", "...")),
+		),
+		graph.Leaf(_this,
+			(ONR("user", "owner", "...")),
+			(ONR("user", "legal", "...")),
+		),
+	)
+
+	fso, ok, err := ms.AddExpansion(ONR("folder", "company", "viewer"), intersection)
+	require.True(ok)
+	require.NoError(err)
+	verifySubjects(t, require, fso, "user:legal")
+}
+
+func TestMembershipSetIntersectionWithDifferentTypesOneMissingRight(t *testing.T) {
+	require := require.New(t)
+	ms := NewMembershipSet()
+
+	intersection := graph.Intersection(ONR("folder", "company", "viewer"),
+		graph.Leaf(_this,
+			(ONR("user", "legal", "...")),
+		),
+		graph.Leaf(_this,
+			(ONR("user", "owner", "...")),
+			(ONR("user", "legal", "...")),
+			(ONR("folder", "foobar", "...")),
+		),
+	)
+
+	fso, ok, err := ms.AddExpansion(ONR("folder", "company", "viewer"), intersection)
+	require.True(ok)
+	require.NoError(err)
+	verifySubjects(t, require, fso, "user:legal")
+}
+
+func TestMembershipSetIntersectionWithDifferentTypes(t *testing.T) {
+	require := require.New(t)
+	ms := NewMembershipSet()
+
+	intersection := graph.Intersection(ONR("folder", "company", "viewer"),
+		graph.Leaf(_this,
+			(ONR("user", "legal", "...")),
+			(ONR("folder", "foobar", "...")),
+			(ONR("folder", "barbaz", "...")),
+		),
+		graph.Leaf(_this,
+			(ONR("user", "owner", "...")),
+			(ONR("user", "legal", "...")),
+			(ONR("folder", "barbaz", "...")),
+		),
+	)
+
+	fso, ok, err := ms.AddExpansion(ONR("folder", "company", "viewer"), intersection)
+	require.True(ok)
+	require.NoError(err)
+	verifySubjects(t, require, fso, "folder:barbaz", "user:legal")
+}
+
 func TestMembershipSetExclusion(t *testing.T) {
 	require := require.New(t)
 	ms := NewMembershipSet()
diff --git a/internal/membership/trackingsubjectset.go b/internal/membership/trackingsubjectset.go
index fe501fbb2d..e55e71f3dc 100644
--- a/internal/membership/trackingsubjectset.go
+++ b/internal/membership/trackingsubjectset.go
@@ -2,171 +2,163 @@ package membership
 
 import (
 	"fmt"
+	"strings"
 
 	core "github.com/authzed/spicedb/pkg/proto/core/v1"
 
-	"github.com/authzed/spicedb/pkg/tuple"
+	"github.com/authzed/spicedb/internal/util"
 )
 
-func isWildcard(subject *core.ObjectAndRelation) bool {
-	return subject.ObjectId == tuple.PublicWildcard
-}
-
 // TrackingSubjectSet defines a set that tracks accessible subjects and their associated
 // relationships.
 //
 // NOTE: This is designed solely for the developer API and testing and should *not* be used in any
 // performance sensitive code.
-//
-// NOTE: Unlike a traditional set, unions between wildcards and a concrete subject will result
-// in *both* being present in the set, to maintain the proper relationship tracking and reporting
-// of concrete subjects.
-//
-// TODO(jschorr): Once we have stable generics support, break into a standard SubjectSet and
-// a tracking variant built on top of it.
-type TrackingSubjectSet map[string]FoundSubject
+type TrackingSubjectSet struct {
+	setByType map[string]util.BaseSubjectSet[FoundSubject]
+}
 
 // NewTrackingSubjectSet creates a new TrackingSubjectSet, with optional initial subjects.
-func NewTrackingSubjectSet(subjects ...FoundSubject) TrackingSubjectSet {
-	var toReturn TrackingSubjectSet = make(map[string]FoundSubject)
-	toReturn.Add(subjects...)
-	return toReturn
+func NewTrackingSubjectSet(subjects ...FoundSubject) *TrackingSubjectSet {
+	tss := &TrackingSubjectSet{
+		setByType: map[string]util.BaseSubjectSet[FoundSubject]{},
+	}
+	for _, subject := range subjects {
+		tss.Add(subject)
+	}
+	return tss
 }
 
 // AddFrom adds the subjects found in the other set to this set.
-func (tss TrackingSubjectSet) AddFrom(otherSet TrackingSubjectSet) {
-	for _, value := range otherSet {
-		tss.Add(value)
+func (tss *TrackingSubjectSet) AddFrom(otherSet *TrackingSubjectSet) {
+	for key, oss := range otherSet.setByType {
+		tss.getSetForKey(key).UnionWithSet(oss)
 	}
 }
 
 // RemoveFrom removes any subjects found in the other set from this set.
-func (tss TrackingSubjectSet) RemoveFrom(otherSet TrackingSubjectSet) {
-	for _, otherSAR := range otherSet {
-		tss.Remove(otherSAR.subject)
+func (tss *TrackingSubjectSet) RemoveFrom(otherSet *TrackingSubjectSet) {
+	for key, oss := range otherSet.setByType {
+		tss.getSetForKey(key).SubtractAll(oss)
 	}
 }
 
 // Add adds the given subjects to this set.
-func (tss TrackingSubjectSet) Add(subjectsAndResources ...FoundSubject) {
-	tss.AddWithResources(subjectsAndResources, nil)
-}
-
-// AddWithResources adds the given subjects to this set, with the additional resources appended
-// for each subject to be included in their relationships.
-func (tss TrackingSubjectSet) AddWithResources(subjectsAndResources []FoundSubject, additionalResources *tuple.ONRSet) {
-	for _, sar := range subjectsAndResources {
-		found, ok := tss[toKey(sar.subject)]
-		if ok {
-			tss[toKey(sar.subject)] = found.union(sar)
-		} else {
-			tss[toKey(sar.subject)] = sar
-		}
+func (tss *TrackingSubjectSet) Add(subjectsAndResources ...FoundSubject) {
+	for _, fs := range subjectsAndResources {
+		tss.getSet(fs).Add(fs)
 	}
 }
 
-// Get returns the found subject in the set, if any.
-func (tss TrackingSubjectSet) Get(subject *core.ObjectAndRelation) (FoundSubject, bool) {
-	found, ok := tss[toKey(subject)]
-	return found, ok
+func keyFor(fs FoundSubject) string {
+	return fmt.Sprintf("%s#%s", fs.subject.Namespace, fs.subject.Relation)
 }
 
-// Contains returns true if the set contains the given subject.
-func (tss TrackingSubjectSet) Contains(subject *core.ObjectAndRelation) bool {
-	_, ok := tss[toKey(subject)]
-	return ok
+func (tss *TrackingSubjectSet) getSetForKey(key string) util.BaseSubjectSet[FoundSubject] {
+	if existing, ok := tss.setByType[key]; ok {
+		return existing
+	}
+
+	parts := strings.Split(key, "#")
+
+	created := util.NewBaseSubjectSet[FoundSubject](
+		func(subjectID string, excludedSubjectIDs []string, sources ...FoundSubject) FoundSubject {
+			fs := NewFoundSubject(&core.ObjectAndRelation{
+				Namespace: parts[0],
+				ObjectId:  subjectID,
+				Relation:  parts[1],
+			})
+			fs.excludedSubjectIds = excludedSubjectIDs
+			for _, source := range sources {
+				fs.relationships.UpdateFrom(source.relationships)
+			}
+			return fs
+		},
+		func(existing FoundSubject, added FoundSubject) FoundSubject {
+			fs := NewFoundSubject(existing.subject)
+			fs.excludedSubjectIds = existing.excludedSubjectIds
+			fs.relationships = existing.relationships.Union(added.relationships)
+			return fs
+		},
+	)
+	tss.setByType[key] = created
+	return created
 }
 
-// removeExact removes the given subject(s) from the set. If the subject is a wildcard, only
-// the exact matching wildcard will be removed.
-func (tss TrackingSubjectSet) removeExact(subjects ...*core.ObjectAndRelation) {
-	for _, subject := range subjects {
-		delete(tss, toKey(subject))
-	}
+func (tss *TrackingSubjectSet) getSet(fs FoundSubject) util.BaseSubjectSet[FoundSubject] {
+	fsKey := keyFor(fs)
+	return tss.getSetForKey(fsKey)
 }
 
-// Remove removes the given subject(s) from the set. If the subject is a wildcard, all matching
-// subjects are removed. If the subject matches a wildcard in the existing set, then it is added
-// to that wildcard as an exclusion.
-func (tss TrackingSubjectSet) Remove(subjects ...*core.ObjectAndRelation) {
-	for _, subject := range subjects {
-		delete(tss, toKey(subject))
-
-		// Delete any entries matching the wildcard, if applicable.
-		if isWildcard(subject) {
-			// Remove any subjects matching the type.
-			for key := range tss {
-				current := fromKey(key)
-				if current.Namespace == subject.Namespace {
-					delete(tss, key)
-				}
-			}
-		} else {
-			// Check for any wildcards matching and, if found, add to the exclusion.
-			for _, existing := range tss {
-				wildcardType, ok := existing.WildcardType()
-				if ok && wildcardType == subject.Namespace {
-					existing.excludedSubjects.Add(subject)
-				}
-			}
-		}
+// Get returns the found subject in the set, if any.
+func (tss *TrackingSubjectSet) Get(subject *core.ObjectAndRelation) (FoundSubject, bool) {
+	set, ok := tss.setByType[fmt.Sprintf("%s#%s", subject.Namespace, subject.Relation)]
+	if !ok {
+		return FoundSubject{}, false
 	}
+
+	return set.Get(subject.ObjectId)
 }
 
-// WithType returns any subjects in the set with the given object type.
-func (tss TrackingSubjectSet) WithType(objectType string) []FoundSubject {
-	toReturn := make([]FoundSubject, 0, len(tss))
-	for _, current := range tss {
-		if current.subject.Namespace == objectType {
-			toReturn = append(toReturn, current)
-		}
-	}
-	return toReturn
+// Contains returns true if the set contains the given subject.
+func (tss *TrackingSubjectSet) Contains(subject *core.ObjectAndRelation) bool {
+	_, ok := tss.Get(subject)
+	return ok
 }
 
 // Exclude returns a new set that contains the items in this set minus those in the other set.
-func (tss TrackingSubjectSet) Exclude(otherSet TrackingSubjectSet) TrackingSubjectSet {
+func (tss *TrackingSubjectSet) Exclude(otherSet *TrackingSubjectSet) *TrackingSubjectSet {
 	newSet := NewTrackingSubjectSet()
-	newSet.AddFrom(tss)
-	newSet.RemoveFrom(otherSet)
+
+	for key, bss := range tss.setByType {
+		cloned := bss.Clone()
+		if oss, ok := otherSet.setByType[key]; ok {
+			cloned.SubtractAll(oss)
+		}
+
+		newSet.setByType[key] = cloned
+	}
+
 	return newSet
 }
 
 // Intersect returns a new set that contains the items in this set *and* the other set. Note that
 // if wildcard is found in *both* sets, it will be returned *along* with any concrete subjects found
 // on the other side of the intersection.
-func (tss TrackingSubjectSet) Intersect(otherSet TrackingSubjectSet) TrackingSubjectSet {
+func (tss *TrackingSubjectSet) Intersect(otherSet *TrackingSubjectSet) *TrackingSubjectSet {
 	newSet := NewTrackingSubjectSet()
-	for _, current := range tss {
-		// Add directly if shared by both.
-		other, ok := otherSet.Get(current.subject)
-		if ok {
-			newSet.Add(current.intersect(other))
-		}
 
-		// If the current is a wildcard, and add any matching.
-		if isWildcard(current.subject) {
-			newSet.AddWithResources(otherSet.WithType(current.subject.Namespace), current.relationships)
+	for key, bss := range tss.setByType {
+		if oss, ok := otherSet.setByType[key]; ok {
+			cloned := bss.Clone()
+			cloned.IntersectionDifference(oss)
+			newSet.setByType[key] = cloned
 		}
 	}
 
-	for _, current := range otherSet {
-		// If the current is a wildcard, add any matching.
-		if isWildcard(current.subject) {
-			newSet.AddWithResources(tss.WithType(current.subject.Namespace), current.relationships)
+	return newSet
+}
+
+// removeExact removes the given subject(s) from the set. If the subject is a wildcard, only
+// the exact matching wildcard will be removed.
+func (tss TrackingSubjectSet) removeExact(subjects ...*core.ObjectAndRelation) {
+	for _, subject := range subjects {
+		if set, ok := tss.setByType[fmt.Sprintf("%s#%s", subject.Namespace, subject.Relation)]; ok {
+			set.UnsafeRemoveExact(FoundSubject{
+				subject: subject,
+			})
 		}
 	}
-
-	return newSet
 }
 
 // ToSlice returns a slice of all subjects found in the set.
 func (tss TrackingSubjectSet) ToSlice() []FoundSubject {
-	toReturn := make([]FoundSubject, 0, len(tss))
-	for _, current := range tss {
-		toReturn = append(toReturn, current)
+	subjects := []FoundSubject{}
+	for _, bss := range tss.setByType {
+		subjects = append(subjects, bss.AsSlice()...)
 	}
-	return toReturn
+
+	return subjects
 }
 
 // ToFoundSubjects returns the set as a FoundSubjects struct.
@@ -174,12 +166,12 @@ func (tss TrackingSubjectSet) ToFoundSubjects() FoundSubjects {
 	return FoundSubjects{tss}
 }
 
-func toKey(subject *core.ObjectAndRelation) string {
-	return fmt.Sprintf("%s %s %s", subject.Namespace, subject.ObjectId, subject.Relation)
-}
-
-func fromKey(key string) *core.ObjectAndRelation {
-	subject := &core.ObjectAndRelation{}
-	fmt.Sscanf(key, "%s %s %s", &subject.Namespace, &subject.ObjectId, &subject.Relation)
-	return subject
+// IsEmpty returns true if the tracking subject set is empty.
+func (tss TrackingSubjectSet) IsEmpty() bool {
+	for _, bss := range tss.setByType {
+		if !bss.IsEmpty() {
+			return false
+		}
+	}
+	return true
 }
diff --git a/internal/membership/trackingsubjectset_test.go b/internal/membership/trackingsubjectset_test.go
index f04e87c935..c5d627b2d0 100644
--- a/internal/membership/trackingsubjectset_test.go
+++ b/internal/membership/trackingsubjectset_test.go
@@ -7,10 +7,11 @@ import (
 
 	core "github.com/authzed/spicedb/pkg/proto/core/v1"
 
+	"github.com/authzed/spicedb/internal/util"
 	"github.com/authzed/spicedb/pkg/tuple"
 )
 
-func set(subjects ...*core.ObjectAndRelation) TrackingSubjectSet {
+func set(subjects ...*core.ObjectAndRelation) *TrackingSubjectSet {
 	newSet := NewTrackingSubjectSet()
 	for _, subject := range subjects {
 		newSet.Add(NewFoundSubject(subject))
@@ -18,15 +19,16 @@ func set(subjects ...*core.ObjectAndRelation) TrackingSubjectSet {
 	return newSet
 }
 
-func union(firstSet TrackingSubjectSet, sets ...TrackingSubjectSet) TrackingSubjectSet {
+func union(firstSet *TrackingSubjectSet, sets ...*TrackingSubjectSet) *TrackingSubjectSet {
 	current := firstSet
 	for _, set := range sets {
 		current.AddFrom(set)
 	}
+
 	return current
 }
 
-func intersect(firstSet TrackingSubjectSet, sets ...TrackingSubjectSet) TrackingSubjectSet {
+func intersect(firstSet *TrackingSubjectSet, sets ...*TrackingSubjectSet) *TrackingSubjectSet {
 	current := firstSet
 	for _, set := range sets {
 		current = current.Intersect(set)
@@ -34,7 +36,7 @@ func intersect(firstSet TrackingSubjectSet, sets ...TrackingSubjectSet) Tracking
 	return current
 }
 
-func exclude(firstSet TrackingSubjectSet, sets ...TrackingSubjectSet) TrackingSubjectSet {
+func subtract(firstSet *TrackingSubjectSet, sets ...*TrackingSubjectSet) *TrackingSubjectSet {
 	current := firstSet
 	for _, set := range sets {
 		current = current.Exclude(set)
@@ -42,18 +44,18 @@ func exclude(firstSet TrackingSubjectSet, sets ...TrackingSubjectSet) TrackingSu
 	return current
 }
 
-func fs(subjectType string, subjectID string, subjectRel string, excludedSubjects ...*core.ObjectAndRelation) FoundSubject {
+func fs(subjectType string, subjectID string, subjectRel string, excludedSubjectIDs ...string) FoundSubject {
 	return FoundSubject{
-		subject:          ONR(subjectType, subjectID, subjectRel),
-		excludedSubjects: tuple.NewONRSet(excludedSubjects...),
-		relationships:    tuple.NewONRSet(),
+		subject:            ONR(subjectType, subjectID, subjectRel),
+		excludedSubjectIds: excludedSubjectIDs,
+		relationships:      tuple.NewONRSet(),
 	}
 }
 
 func TestTrackingSubjectSet(t *testing.T) {
 	testCases := []struct {
 		name     string
-		set      TrackingSubjectSet
+		set      *TrackingSubjectSet
 		expected []FoundSubject
 	}{
 		{
@@ -108,7 +110,7 @@ func TestTrackingSubjectSet(t *testing.T) {
 		},
 		{
 			"simple exclusion",
-			exclude(
+			subtract(
 				set(
 					(ONR("user", "user1", "...")),
 					(ONR("user", "user2", "...")),
@@ -120,7 +122,7 @@ func TestTrackingSubjectSet(t *testing.T) {
 		},
 		{
 			"empty exclusion",
-			exclude(
+			subtract(
 				set(
 					(ONR("user", "user1", "...")),
 					(ONR("user", "user2", "...")),
@@ -158,7 +160,7 @@ func TestTrackingSubjectSet(t *testing.T) {
 		},
 		{
 			"wildcard left side exclusion",
-			exclude(
+			subtract(
 				set(
 					(ONR("user", "*", "...")),
 					(ONR("user", "user2", "...")),
@@ -166,13 +168,13 @@ func TestTrackingSubjectSet(t *testing.T) {
 				set(ONR("user", "user1", "...")),
 			),
 			[]FoundSubject{
-				fs("user", "*", "...", ONR("user", "user1", "...")),
+				fs("user", "*", "...", "user1"),
 				fs("user", "user2", "..."),
 			},
 		},
 		{
 			"wildcard right side exclusion",
-			exclude(
+			subtract(
 				set(
 					(ONR("user", "user2", "...")),
 				),
@@ -182,19 +184,19 @@ func TestTrackingSubjectSet(t *testing.T) {
 		},
 		{
 			"wildcard right side concrete exclusion",
-			exclude(
+			subtract(
 				set(
 					(ONR("user", "*", "...")),
 				),
 				set(ONR("user", "user1", "...")),
 			),
 			[]FoundSubject{
-				fs("user", "*", "...", ONR("user", "user1", "...")),
+				fs("user", "*", "...", "user1"),
 			},
 		},
 		{
 			"wildcard both sides exclusion",
-			exclude(
+			subtract(
 				set(
 					(ONR("user", "user2", "...")),
 					(ONR("user", "*", "...")),
@@ -249,70 +251,72 @@ func TestTrackingSubjectSet(t *testing.T) {
 		{
 			"wildcard with exclusions union",
 			union(
-				NewTrackingSubjectSet(fs("user", "*", "...", ONR("user", "user1", "..."))),
-				NewTrackingSubjectSet(fs("user", "*", "...", ONR("user", "user2", "..."))),
+				NewTrackingSubjectSet(fs("user", "*", "...", "user1")),
+				NewTrackingSubjectSet(fs("user", "*", "...", "user2")),
 			),
 			[]FoundSubject{
-				fs("user", "*", "...", ONR("user", "user1", "..."), ONR("user", "user2", "...")),
+				fs("user", "*", "..."),
 			},
 		},
 		{
 			"wildcard with exclusions intersection",
 			intersect(
-				NewTrackingSubjectSet(fs("user", "*", "...", ONR("user", "user1", "..."))),
-				NewTrackingSubjectSet(fs("user", "*", "...", ONR("user", "user2", "..."))),
+				NewTrackingSubjectSet(fs("user", "*", "...", "user1")),
+				NewTrackingSubjectSet(fs("user", "*", "...", "user2")),
 			),
 			[]FoundSubject{
-				fs("user", "*", "...", ONR("user", "user1", "..."), ONR("user", "user2", "...")),
+				fs("user", "*", "...", "user1", "user2"),
 			},
 		},
 		{
-			"wildcard with exclusions exclusion",
-			exclude(
+			"wildcard with exclusions over subtraction",
+			subtract(
 				NewTrackingSubjectSet(
-					fs("user", "*", "...", ONR("user", "user1", "...")),
+					fs("user", "*", "...", "user1"),
 				),
-				NewTrackingSubjectSet(fs("user", "*", "...", ONR("user", "user2", "..."))),
+				NewTrackingSubjectSet(fs("user", "*", "...", "user2")),
 			),
-			[]FoundSubject{},
+			[]FoundSubject{
+				fs("user", "user2", "..."),
+			},
 		},
 		{
 			"wildcard with exclusions excluded user added",
-			exclude(
+			subtract(
 				NewTrackingSubjectSet(
-					fs("user", "*", "...", ONR("user", "user1", "...")),
+					fs("user", "*", "...", "user1"),
 				),
 				NewTrackingSubjectSet(fs("user", "user2", "...")),
 			),
 			[]FoundSubject{
-				fs("user", "*", "...", ONR("user", "user1", "..."), ONR("user", "user2", "...")),
+				fs("user", "*", "...", "user1", "user2"),
 			},
 		},
 		{
 			"wildcard multiple exclusions",
-			exclude(
+			subtract(
 				NewTrackingSubjectSet(
-					fs("user", "*", "...", ONR("user", "user1", "...")),
+					fs("user", "*", "...", "user1"),
 				),
 				NewTrackingSubjectSet(fs("user", "user2", "...")),
 				NewTrackingSubjectSet(fs("user", "user3", "...")),
 			),
 			[]FoundSubject{
-				fs("user", "*", "...", ONR("user", "user1", "..."), ONR("user", "user2", "..."), ONR("user", "user3", "...")),
+				fs("user", "*", "...", "user1", "user2", "user3"),
 			},
 		},
 		{
 			"intersection of exclusions",
 			intersect(
 				NewTrackingSubjectSet(
-					fs("user", "*", "...", ONR("user", "user1", "...")),
+					fs("user", "*", "...", "user1"),
 				),
 				NewTrackingSubjectSet(
-					fs("user", "*", "...", ONR("user", "user2", "...")),
+					fs("user", "*", "...", "user2"),
 				),
 			),
 			[]FoundSubject{
-				fs("user", "*", "...", ONR("user", "user1", "..."), ONR("user", "user2", "...")),
+				fs("user", "*", "...", "user1", "user2"),
 			},
 		},
 	}
@@ -320,24 +324,54 @@ func TestTrackingSubjectSet(t *testing.T) {
 	for _, tc := range testCases {
 		t.Run(tc.name, func(t *testing.T) {
 			require := require.New(t)
-
 			for _, fs := range tc.expected {
 				_, isWildcard := fs.WildcardType()
 				if isWildcard {
 					found, ok := tc.set.Get(fs.subject)
 					require.True(ok, "missing expected subject %s", fs.subject)
 
-					expectedExcluded := fs.excludedSubjects.AsSlice()
-					foundExcluded := found.excludedSubjects.AsSlice()
-					require.Len(fs.excludedSubjects.Subtract(found.excludedSubjects).AsSlice(), 0, "mismatch on excluded subjects on %s: expected: %s, found: %s", fs.subject, expectedExcluded, foundExcluded)
-					require.Len(found.excludedSubjects.Subtract(fs.excludedSubjects).AsSlice(), 0, "mismatch on excluded subjects on %s: expected: %s, found: %s", fs.subject, expectedExcluded, foundExcluded)
+					expectedExcluded := util.NewSet[string](fs.excludedSubjectIds...)
+					foundExcluded := util.NewSet[string](found.excludedSubjectIds...)
+					require.Len(expectedExcluded.Subtract(foundExcluded).AsSlice(), 0, "mismatch on excluded subjects on %s: expected: %s, found: %s", fs.subject, expectedExcluded, foundExcluded)
+					require.Len(foundExcluded.Subtract(expectedExcluded).AsSlice(), 0, "mismatch on excluded subjects on %s: expected: %s, found: %s", fs.subject, expectedExcluded, foundExcluded)
 				} else {
 					require.True(tc.set.Contains(fs.subject), "missing expected subject %s", fs.subject)
 				}
 				tc.set.removeExact(fs.subject)
 			}
 
-			require.Len(tc.set, 0)
+			require.True(tc.set.IsEmpty())
 		})
 	}
 }
+
+func TestTrackingSubjectSetResourceTracking(t *testing.T) {
+	tss := NewTrackingSubjectSet()
+	tss.Add(NewFoundSubject(ONR("user", "tom", "..."), ONR("resource", "foo", "viewer")))
+	tss.Add(NewFoundSubject(ONR("user", "tom", "..."), ONR("resource", "bar", "viewer")))
+
+	found, ok := tss.Get(ONR("user", "tom", "..."))
+	require.True(t, ok)
+	require.Equal(t, 2, len(found.Relationships()))
+
+	sss := NewTrackingSubjectSet()
+	sss.Add(NewFoundSubject(ONR("user", "tom", "..."), ONR("resource", "baz", "viewer")))
+
+	intersection := tss.Intersect(sss)
+	found, ok = intersection.Get(ONR("user", "tom", "..."))
+	require.True(t, ok)
+	require.Equal(t, 3, len(found.Relationships()))
+}
+
+func TestTrackingSubjectSetResourceTrackingWithWildcard(t *testing.T) {
+	tss := NewTrackingSubjectSet()
+	tss.Add(NewFoundSubject(ONR("user", "tom", "..."), ONR("resource", "foo", "viewer")))
+
+	sss := NewTrackingSubjectSet()
+	sss.Add(NewFoundSubject(ONR("user", "*", "..."), ONR("resource", "baz", "viewer")))
+
+	intersection := tss.Intersect(sss)
+	found, ok := intersection.Get(ONR("user", "tom", "..."))
+	require.True(t, ok)
+	require.Equal(t, 1, len(found.Relationships()))
+}
diff --git a/internal/services/consistency_test.go b/internal/services/consistency_test.go
index 0f97c149bf..33b471a124 100644
--- a/internal/services/consistency_test.go
+++ b/internal/services/consistency_test.go
@@ -343,8 +343,11 @@ func runConsistencyTests(t *testing.T,
 	validateExpansionSubjects(t, ds, vctx)
 
 	// For each relation in each namespace, for each user, collect the objects accessible
-	// to that user and then verify the lookup returns the same set of objects.
-	validateLookup(t, vctx)
+	// to that user and then verify the lookup resources returns the same set of objects.
+	validateLookupResources(t, vctx)
+
+	// For each object accessible, validate that the subjects that can access it are found.
+	validateLookupSubject(t, vctx)
 
 	// Run the developer APIs over the full set of context and ensure they also return the expected information.
 	store := v0svc.NewInMemoryShareStore("flavored")
@@ -435,9 +438,10 @@ func validateValidation(t *testing.T, dev v0.DeveloperServiceServer, reqContext
 			require.True(t,
 				(vctx.accessibilitySet.GetIsMember(onr, subjectWithExceptions.Subject) == isMember ||
 					vctx.accessibilitySet.GetIsMember(onr, subjectWithExceptions.Subject) == isWildcard),
-				"Generated expected relations returned inaccessible member %s for %s",
+				"Generated expected relations returned inaccessible member %s for %s in `%s`",
 				tuple.StringONR(subjectWithExceptions.Subject),
-				tuple.StringONR(onr))
+				tuple.StringONR(onr),
+				updatedValidationYaml)
 		}
 	}
 
@@ -523,7 +527,7 @@ func validateEditChecks(t *testing.T, dev v0.DeveloperServiceServer, reqContext
 	}
 }
 
-func validateLookup(t *testing.T, vctx *validationContext) {
+func validateLookupResources(t *testing.T, vctx *validationContext) {
 	for _, nsDef := range vctx.fullyResolved.NamespaceDefinitions {
 		for _, relation := range nsDef.Relation {
 			for _, subject := range vctx.subjectsNoWildcard.AsSlice() {
@@ -532,7 +536,7 @@ func validateLookup(t *testing.T, vctx *validationContext) {
 					Relation:  relation.Name,
 				}
 
-				t.Run(fmt.Sprintf("lookup_%s_%s_to_%s_%s_%s", objectRelation.Namespace, objectRelation.Relation, subject.Namespace, subject.ObjectId, subject.Relation), func(t *testing.T) {
+				t.Run(fmt.Sprintf("lookupresources_%s_%s_to_%s_%s_%s", objectRelation.Namespace, objectRelation.Relation, subject.Namespace, subject.ObjectId, subject.Relation), func(t *testing.T) {
 					vrequire := require.New(t)
 					accessibleObjectIds := vctx.accessibilitySet.AccessibleObjectIDs(objectRelation.Namespace, objectRelation.Relation, subject)
 
@@ -583,6 +587,79 @@ func validateLookup(t *testing.T, vctx *validationContext) {
 	}
 }
 
+func validateLookupSubject(t *testing.T, vctx *validationContext) {
+	for _, nsDef := range vctx.fullyResolved.NamespaceDefinitions {
+		allObjectIds, ok := vctx.objectsPerNamespace.Get(nsDef.Name)
+		if !ok {
+			continue
+		}
+
+		for _, relation := range nsDef.Relation {
+			for _, objectID := range allObjectIds {
+				objectIDStr := objectID.(string)
+
+				accessibleSubjectsByType := vctx.accessibilitySet.AccessibleSubjectsByType(nsDef.Name, relation.Name, objectIDStr)
+				accessibleSubjectsByType.ForEachType(func(subjectType *core.RelationReference, expectedObjectIds []string) {
+					t.Run(fmt.Sprintf("lookupsubjects_%s_%s_%s_to_%s_%s", nsDef.Name, relation.Name, objectID, subjectType.Namespace, subjectType.Relation), func(t *testing.T) {
+						vrequire := require.New(t)
+
+						// Perform a lookup call and ensure it returns the at least the same set of object IDs.
+						resource := &core.ObjectAndRelation{
+							Namespace: nsDef.Name,
+							ObjectId:  objectIDStr,
+							Relation:  relation.Name,
+						}
+						resolvedObjectIds, err := vctx.tester.LookupSubjects(context.Background(), resource, subjectType, vctx.revision)
+						vrequire.NoError(err)
+
+						sort.Strings(expectedObjectIds)
+						sort.Strings(resolvedObjectIds)
+
+						// Ensure the object IDs match.
+						for _, expectedObjectID := range expectedObjectIds {
+							vrequire.True(
+								contains(resolvedObjectIds, expectedObjectID),
+								"Object `%s` missing in lookup subjects results for subjects of %s under %s: Expected: %v. Found: %v",
+								expectedObjectID,
+								tuple.StringRR(subjectType),
+								tuple.StringONR(resource),
+								expectedObjectIds,
+								resolvedObjectIds,
+							)
+						}
+
+						// Ensure that every returned object Checks.
+						for _, resolvedObjectID := range resolvedObjectIds {
+							if resolvedObjectID == tuple.PublicWildcard {
+								continue
+							}
+
+							subject := &core.ObjectAndRelation{
+								Namespace: subjectType.Namespace,
+								ObjectId:  resolvedObjectID,
+								Relation:  subjectType.Relation,
+							}
+							isMember, err := vctx.tester.Check(context.Background(),
+								resource,
+								subject,
+								vctx.revision,
+							)
+							vrequire.NoError(err)
+							vrequire.True(
+								isMember,
+								"Found Check failure for resource %s and subject %s",
+								nsDef.Name,
+								tuple.StringONR(resource),
+								tuple.StringONR(subject),
+							)
+						}
+					})
+				})
+			}
+		}
+	}
+}
+
 func validateExpansion(t *testing.T, vctx *validationContext) {
 	for _, nsDef := range vctx.fullyResolved.NamespaceDefinitions {
 		allObjectIds, ok := vctx.objectsPerNamespace.Get(nsDef.Name)
@@ -803,8 +880,23 @@ func (rs *accessibilitySet) AccessibleObjectIDs(namespaceName string, relationNa
 	return accessibleObjectIDs
 }
 
+// AccessibleSubjects returns the set of subjects with accessible for the given object on the given relation on the namespace
+func (rs *accessibilitySet) AccessibleSubjectsByType(namespaceName string, relationName string, objectIDStr string) *tuple.ONRByTypeSet {
+	accessibleSubjects := tuple.NewONRByTypeSet()
+	for _, result := range rs.results {
+		if result.isMember == isNotMember || result.isMember == isWildcard || result.isMember == isMemberViaWildcard {
+			continue
+		}
+
+		if result.object.Namespace == namespaceName && result.object.Relation == relationName && result.object.ObjectId == objectIDStr {
+			accessibleSubjects.Add(result.subject)
+		}
+	}
+	return accessibleSubjects
+}
+
 // AccessibleTerminalSubjects returns the set of terminal subjects with accessible for the given object on the given relation on the namespace
-func (rs *accessibilitySet) AccessibleTerminalSubjects(namespaceName string, relationName string, objectIDStr string) membership.TrackingSubjectSet {
+func (rs *accessibilitySet) AccessibleTerminalSubjects(namespaceName string, relationName string, objectIDStr string) *membership.TrackingSubjectSet {
 	accessibleSubjects := membership.NewTrackingSubjectSet()
 	for _, result := range rs.results {
 		if result.isMember == isNotMember || result.isMember == isWildcard {
diff --git a/internal/services/dispatch/v1/acl.go b/internal/services/dispatch/v1/acl.go
index a7377c9770..0dfc8285b4 100644
--- a/internal/services/dispatch/v1/acl.go
+++ b/internal/services/dispatch/v1/acl.go
@@ -56,6 +56,14 @@ func (ds *dispatchServer) DispatchReachableResources(
 		dispatch.WrapGRPCStream[*dispatchv1.DispatchReachableResourcesResponse](resp))
 }
 
+func (ds *dispatchServer) DispatchLookupSubjects(
+	req *dispatchv1.DispatchLookupSubjectsRequest,
+	resp dispatchv1.DispatchService_DispatchLookupSubjectsServer,
+) error {
+	return ds.localDispatch.DispatchLookupSubjects(req,
+		dispatch.WrapGRPCStream[*dispatchv1.DispatchLookupSubjectsResponse](resp))
+}
+
 func (ds *dispatchServer) Close() error {
 	return nil
 }
diff --git a/internal/services/servicetester_test.go b/internal/services/servicetester_test.go
index 1f91c632fc..c6509f0d06 100644
--- a/internal/services/servicetester_test.go
+++ b/internal/services/servicetester_test.go
@@ -23,6 +23,7 @@ type serviceTester interface {
 	Write(ctx context.Context, relationship *core.RelationTuple) error
 	Read(ctx context.Context, namespaceName string, atRevision decimal.Decimal) ([]*core.RelationTuple, error)
 	Lookup(ctx context.Context, resourceRelation *core.RelationReference, subject *core.ObjectAndRelation, atRevision decimal.Decimal) ([]string, error)
+	LookupSubjects(ctx context.Context, resource *core.ObjectAndRelation, subjectRelation *core.RelationReference, atRevision decimal.Decimal) ([]string, error)
 }
 
 func optionalizeRelation(relation string) string {
@@ -189,3 +190,40 @@ func (v1st v1ServiceTester) Lookup(ctx context.Context, resourceRelation *core.R
 	sort.Strings(objectIds)
 	return objectIds, nil
 }
+
+func (v1st v1ServiceTester) LookupSubjects(ctx context.Context, resource *core.ObjectAndRelation, subjectRelation *core.RelationReference, atRevision decimal.Decimal) ([]string, error) {
+	lookupResp, err := v1st.permClient.LookupSubjects(context.Background(), &v1.LookupSubjectsRequest{
+		Resource: &v1.ObjectReference{
+			ObjectType: resource.Namespace,
+			ObjectId:   resource.ObjectId,
+		},
+		Permission:              resource.Relation,
+		SubjectObjectType:       subjectRelation.Namespace,
+		OptionalSubjectRelation: optionalizeRelation(subjectRelation.Relation),
+		Consistency: &v1.Consistency{
+			Requirement: &v1.Consistency_AtLeastAsFresh{
+				AtLeastAsFresh: zedtoken.NewFromRevision(atRevision),
+			},
+		},
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	var objectIds []string
+	for {
+		resp, err := lookupResp.Recv()
+		if errors.Is(err, io.EOF) {
+			break
+		}
+
+		if err != nil {
+			return nil, err
+		}
+
+		objectIds = append(objectIds, resp.SubjectObjectId)
+	}
+
+	sort.Strings(objectIds)
+	return objectIds, nil
+}
diff --git a/internal/services/testconfigs/nestedwilcardexclusions.yaml b/internal/services/testconfigs/nestedwilcardexclusions.yaml
new file mode 100644
index 0000000000..f6508594ac
--- /dev/null
+++ b/internal/services/testconfigs/nestedwilcardexclusions.yaml
@@ -0,0 +1,21 @@
+---
+schema: >-
+  definition test/user {}
+
+  definition test/resource {
+    relation viewer: test/user | test/user:*
+    relation maybebanned: test/user | test/user:*
+    relation notreallybanned: test/user | test/user:*
+
+    permission possiblybanned = maybebanned - notreallybanned
+    permission view = viewer - possiblybanned
+  }
+relationships: |
+  test/resource:first#viewer@test/user:*
+  test/resource:first#maybebanned@test/user:*
+  test/resource:first#notreallybanned@test/user:sarah
+assertions:
+  assertTrue:
+    - "test/resource:first#view@test/user:sarah"
+  assertFalse:
+    - "test/resource:first#view@test/user:tom"
diff --git a/internal/services/testconfigs/wildcardwithnestedexclusions.yaml b/internal/services/testconfigs/wildcardwithnestedexclusions.yaml
new file mode 100644
index 0000000000..ef2548a2e6
--- /dev/null
+++ b/internal/services/testconfigs/wildcardwithnestedexclusions.yaml
@@ -0,0 +1,22 @@
+---
+schema: >-
+  definition test/user {}
+
+  definition test/resource {
+    relation viewer: test/user | test/user:*
+    relation maybebanned: test/user
+    relation notreallybanned: test/user
+
+    permission possiblybanned = maybebanned - notreallybanned
+    permission view = viewer - possiblybanned
+  }
+relationships: |
+  test/resource:first#viewer@test/user:*
+  test/resource:first#maybebanned@test/user:tom
+  test/resource:first#maybebanned@test/user:sarah
+  test/resource:first#notreallybanned@test/user:sarah
+assertions:
+  assertTrue:
+    - "test/resource:first#view@test/user:sarah"
+  assertFalse:
+    - "test/resource:first#view@test/user:tom"
diff --git a/internal/services/v1/permissions.go b/internal/services/v1/permissions.go
index 6ade020b5f..ea53293efe 100644
--- a/internal/services/v1/permissions.go
+++ b/internal/services/v1/permissions.go
@@ -4,6 +4,8 @@ import (
 	"context"
 	"fmt"
 
+	"github.com/authzed/spicedb/pkg/tuple"
+
 	"github.com/authzed/authzed-go/pkg/requestmeta"
 
 	"github.com/authzed/authzed-go/pkg/responsemeta"
@@ -368,6 +370,84 @@ func (ps *permissionServer) LookupResources(req *v1.LookupResourcesRequest, resp
 	return nil
 }
 
+func (ps *permissionServer) LookupSubjects(req *v1.LookupSubjectsRequest, resp v1.PermissionsService_LookupSubjectsServer) error {
+	ctx := resp.Context()
+	atRevision, revisionReadAt := consistency.MustRevisionFromContext(ctx)
+
+	ds := datastoremw.MustFromContext(ctx).SnapshotReader(atRevision)
+
+	// Perform our preflight checks in parallel
+	errG, checksCtx := errgroup.WithContext(ctx)
+	errG.Go(func() error {
+		return namespace.CheckNamespaceAndRelation(
+			checksCtx,
+			req.Resource.ObjectType,
+			req.Permission,
+			false,
+			ds,
+		)
+	})
+	errG.Go(func() error {
+		return namespace.CheckNamespaceAndRelation(
+			ctx,
+			req.SubjectObjectType,
+			stringz.DefaultEmpty(req.OptionalSubjectRelation, tuple.Ellipsis),
+			true,
+			ds,
+		)
+	})
+	if err := errG.Wait(); err != nil {
+		return rewritePermissionsError(ctx, err)
+	}
+
+	respMetadata := &dispatch.ResponseMeta{
+		DispatchCount:       0,
+		CachedDispatchCount: 0,
+		DepthRequired:       0,
+		DebugInfo:           nil,
+	}
+	usagemetrics.SetInContext(ctx, respMetadata)
+
+	stream := dispatchpkg.NewHandlingDispatchStream(ctx, func(result *dispatch.DispatchLookupSubjectsResponse) error {
+		for _, foundSubject := range result.FoundSubjects {
+			err := resp.Send(&v1.LookupSubjectsResponse{
+				SubjectObjectId:    foundSubject.SubjectId,
+				ExcludedSubjectIds: foundSubject.ExcludedSubjectIds,
+				LookedUpAt:         revisionReadAt,
+			})
+			if err != nil {
+				return err
+			}
+		}
+
+		dispatchpkg.AddResponseMetadata(respMetadata, result.Metadata)
+		return nil
+	})
+
+	err := ps.dispatch.DispatchLookupSubjects(
+		&dispatch.DispatchLookupSubjectsRequest{
+			Metadata: &dispatch.ResolverMeta{
+				AtRevision:     atRevision.String(),
+				DepthRemaining: ps.defaultDepth,
+			},
+			ResourceRelation: &core.RelationReference{
+				Namespace: req.Resource.ObjectType,
+				Relation:  req.Permission,
+			},
+			ResourceIds: []string{req.Resource.ObjectId},
+			SubjectRelation: &core.RelationReference{
+				Namespace: req.SubjectObjectType,
+				Relation:  stringz.DefaultEmpty(req.OptionalSubjectRelation, tuple.Ellipsis),
+			},
+		},
+		stream)
+	if err != nil {
+		return rewritePermissionsError(ctx, err)
+	}
+
+	return nil
+}
+
 func normalizeSubjectRelation(sub *v1.SubjectReference) string {
 	if sub.OptionalRelation == "" {
 		return graph.Ellipsis
diff --git a/internal/services/v1/permissions_test.go b/internal/services/v1/permissions_test.go
index 5276a1a861..31acc8cdef 100644
--- a/internal/services/v1/permissions_test.go
+++ b/internal/services/v1/permissions_test.go
@@ -665,3 +665,152 @@ func TestTranslateExpansionTree(t *testing.T) {
 		})
 	}
 }
+
+func TestLookupSubjects(t *testing.T) {
+	testCases := []struct {
+		resource        *v1.ObjectReference
+		permission      string
+		subjectType     string
+		subjectRelation string
+
+		expectedSubjectIds []string
+		expectedErrorCode  codes.Code
+	}{
+		{
+			obj("document", "companyplan"),
+			"view",
+			"user",
+			"",
+			[]string{"auditor", "legal", "owner"},
+			codes.OK,
+		},
+		{
+			obj("document", "healthplan"),
+			"view",
+			"user",
+			"",
+			[]string{"chief_financial_officer"},
+			codes.OK,
+		},
+		{
+			obj("document", "masterplan"),
+			"view",
+			"user",
+			"",
+			[]string{"auditor", "chief_financial_officer", "eng_lead", "legal", "owner", "product_manager", "vp_product"},
+			codes.OK,
+		},
+		{
+			obj("document", "masterplan"),
+			"view_and_edit",
+			"user",
+			"",
+			nil,
+			codes.OK,
+		},
+		{
+			obj("document", "specialplan"),
+			"view_and_edit",
+			"user",
+			"",
+			[]string{"multiroleguy"},
+			codes.OK,
+		},
+		{
+			obj("document", "unknownobj"),
+			"view",
+			"user",
+			"",
+			nil,
+			codes.OK,
+		},
+		{
+			obj("document", "masterplan"),
+			"invalidperm",
+			"user",
+			"",
+			nil,
+			codes.FailedPrecondition,
+		},
+		{
+			obj("document", "masterplan"),
+			"view",
+			"invalidsubtype",
+			"",
+			nil,
+			codes.FailedPrecondition,
+		},
+		{
+			obj("unknown", "masterplan"),
+			"view",
+			"user",
+			"",
+			nil,
+			codes.FailedPrecondition,
+		},
+		{
+			obj("document", "masterplan"),
+			"view",
+			"user",
+			"invalidrel",
+			nil,
+			codes.FailedPrecondition,
+		},
+	}
+
+	for _, delta := range testTimedeltas {
+		t.Run(fmt.Sprintf("fuzz%d", delta/time.Millisecond), func(t *testing.T) {
+			for _, tc := range testCases {
+				t.Run(fmt.Sprintf("%s:%s#%s for %s#%s", tc.resource.ObjectType, tc.resource.ObjectId, tc.permission, tc.subjectType, tc.subjectRelation), func(t *testing.T) {
+					require := require.New(t)
+					conn, cleanup, _, revision := testserver.NewTestServer(require, delta, memdb.DisableGC, true, tf.StandardDatastoreWithData)
+					client := v1.NewPermissionsServiceClient(conn)
+					t.Cleanup(func() {
+						goleak.VerifyNone(t, goleak.IgnoreCurrent())
+					})
+					t.Cleanup(cleanup)
+
+					var trailer metadata.MD
+					lookupClient, err := client.LookupSubjects(context.Background(), &v1.LookupSubjectsRequest{
+						Resource:                tc.resource,
+						Permission:              tc.permission,
+						SubjectObjectType:       tc.subjectType,
+						OptionalSubjectRelation: tc.subjectRelation,
+						Consistency: &v1.Consistency{
+							Requirement: &v1.Consistency_AtLeastAsFresh{
+								AtLeastAsFresh: zedtoken.NewFromRevision(revision),
+							},
+						},
+					}, grpc.Trailer(&trailer))
+
+					require.NoError(err)
+					if tc.expectedErrorCode == codes.OK {
+						var resolvedObjectIds []string
+						for {
+							resp, err := lookupClient.Recv()
+							if errors.Is(err, io.EOF) {
+								break
+							}
+
+							require.NoError(err)
+
+							resolvedObjectIds = append(resolvedObjectIds, resp.SubjectObjectId)
+						}
+
+						sort.Strings(tc.expectedSubjectIds)
+						sort.Strings(resolvedObjectIds)
+
+						require.Equal(tc.expectedSubjectIds, resolvedObjectIds)
+
+						dispatchCount, err := responsemeta.GetIntResponseTrailerMetadata(trailer, responsemeta.DispatchedOperationsCount)
+						require.NoError(err)
+						require.GreaterOrEqual(dispatchCount, 0)
+					} else {
+						_, err := lookupClient.Recv()
+						grpcutil.RequireStatus(t, tc.expectedErrorCode, err)
+					}
+				})
+			}
+		})
+	}
+}
diff --git a/internal/util/basesubjectset.go b/internal/util/basesubjectset.go
new file mode 100644
index 0000000000..b47bb47903
--- /dev/null
+++ b/internal/util/basesubjectset.go
@@ -0,0 +1,255 @@
+package util
+
+import (
+	"golang.org/x/exp/maps"
+
+	"github.com/authzed/spicedb/pkg/tuple"
+)
+
+// Subject is a subject that can be placed into a BaseSubjectSet.
+type Subject interface {
+	// GetSubjectId returns the ID of the subject. For wildcards, this should be `*`.
+	GetSubjectId() string
+
+	// GetExcludedSubjectIds returns the list of subject IDs excluded. Should only have values
+	// for wildcards.
+	GetExcludedSubjectIds() []string
+}
+
+// BaseSubjectSet defines a set that tracks accessible subjects. It is generic to allow
+// other implementations to define the kind of tracking information associated with each subject.
+//
+// NOTE: Unlike a traditional set, unions between wildcards and a concrete subject will result
+// in *both* being present in the set, to maintain the proper set semantics around wildcards.
+type BaseSubjectSet[T Subject] struct {
+	values      map[string]T
+	constructor func(subjectID string, excludedSubjectIDs []string, sources ...T) T
+	combiner    func(existing T, added T) T
+}
+
+// NewBaseSubjectSet creates a new base subject set for use underneath well-typed implementation.
+//
+// The constructor function is a function that returns a new instancre of type T for a particular
+// subject ID.
+// The combiner function is optional, and if given, is used to combine existing elements in the
+// set into a new element. This is typically used in debug packages for tracking of additional
+// metadata.
+func NewBaseSubjectSet[T Subject](
+	constructor func(subjectID string, excludedSubjectIDs []string, sources ...T) T,
+	combiner func(existing T, added T) T,
+) BaseSubjectSet[T] {
+	return BaseSubjectSet[T]{
+		values:      map[string]T{},
+		constructor: constructor,
+		combiner:    combiner,
+	}
+}
+
+// Add adds the found subject to the set. This is equivalent to a Union operation between the
+// existing set of subjects and a set containing the single subject.
+func (bss BaseSubjectSet[T]) Add(foundSubject T) bool {
+	existing, ok := bss.values[foundSubject.GetSubjectId()]
+	if !ok {
+		bss.values[foundSubject.GetSubjectId()] = foundSubject
+	}
+
+	if foundSubject.GetSubjectId() == tuple.PublicWildcard {
+		if ok {
+			// Intersect any exceptions, as union between one wildcard and another is a wildcard
+			// with the exceptions intersected.
+			//
+			// As a concrete example, given `user:* - user:tom` and `user:* - user:sarah`, the union
+			// of the two will be `*`, since each handles the other user.
+			excludedIds := NewSet[string](existing.GetExcludedSubjectIds()...).IntersectionDifference(NewSet[string](foundSubject.GetExcludedSubjectIds()...))
+			bss.values[tuple.PublicWildcard] = bss.constructor(tuple.PublicWildcard, excludedIds.AsSlice(), existing, foundSubject)
+		}
+	} else {
+		// If there is an existing wildcard, remove the subject from its exclusions list.
+		if existingWildcard, ok := bss.values[tuple.PublicWildcard]; ok {
+			excludedIds := NewSet[string](existingWildcard.GetExcludedSubjectIds()...)
+			excludedIds.Remove(foundSubject.GetSubjectId())
+			bss.values[tuple.PublicWildcard] = bss.constructor(tuple.PublicWildcard, excludedIds.AsSlice(), existingWildcard)
+		}
+	}
+
+	if bss.combiner != nil && ok {
+		bss.values[foundSubject.GetSubjectId()] = bss.combiner(bss.values[foundSubject.GetSubjectId()], foundSubject)
+	}
+
+	return !ok
+}
+
+// Subtract subtracts the given subject found the set.
+func (bss BaseSubjectSet[T]) Subtract(foundSubject T) {
+	// If the subject being removed is a wildcard, then remove any non-excluded items and adjust
+	// the existing wildcard.
+	if foundSubject.GetSubjectId() == tuple.PublicWildcard {
+		exclusions := NewSet[string](foundSubject.GetExcludedSubjectIds()...)
+		for existingSubjectID := range bss.values {
+			if existingSubjectID == tuple.PublicWildcard {
+				continue
+			}
+
+			if !exclusions.Has(existingSubjectID) {
+				delete(bss.values, existingSubjectID)
+			}
+		}
+
+		// Check for an existing wildcard and adjust accordingly.
+		if existing, ok := bss.values[tuple.PublicWildcard]; ok {
+			// A subtraction of a wildcard from another wildcard subtracts the exclusions from the second.
+			// from the first, and places them into the subject set directly.
+			//
+			// As a concrete example, given `user:* - user:tom` - `user:* - user:sarah`, the subtraction
+			// of the two will be `user:sarah`, since sarah is in the first set and not in the second.
+			existingExclusions := NewSet[string](existing.GetExcludedSubjectIds()...)
+			for _, subjectID := range foundSubject.GetExcludedSubjectIds() {
+				if !existingExclusions.Has(subjectID) {
+					bss.values[subjectID] = bss.constructor(subjectID, nil, foundSubject)
+				}
+			}
+		}
+
+		delete(bss.values, tuple.PublicWildcard)
+		return
+	}
+
+	// Remove the subject itself from the set.
+	delete(bss.values, foundSubject.GetSubjectId())
+
+	// If wildcard exists within the subject set, add the found subject to the exclusion list.
+	if wildcard, ok := bss.values[tuple.PublicWildcard]; ok {
+		exclusions := NewSet[string](wildcard.GetExcludedSubjectIds()...)
+		exclusions.Add(foundSubject.GetSubjectId())
+		bss.values[tuple.PublicWildcard] = bss.constructor(tuple.PublicWildcard, exclusions.AsSlice(), wildcard)
+	}
+}
+
+// SubtractAll subtracts the other set of subjects from this set of subtracts, modifying this
+// set in place.
+func (bss BaseSubjectSet[T]) SubtractAll(other BaseSubjectSet[T]) {
+	for _, fs := range other.values {
+		bss.Subtract(fs)
+	}
+}
+
+// IntersectionDifference performs an intersection between this set and the other set, modifying
+// this set in place.
+func (bss BaseSubjectSet[T]) IntersectionDifference(other BaseSubjectSet[T]) {
+	// Check if the other set has a wildcard. If so, remove any subjects found in the exclusion
+	// list.
+	//
+	// As a concrete example, given `user:tom` and `user:* - user:sarah`, the intersection should
+	// return `user:tom`, because everyone but `sarah` (including `tom`) is in the second set.
+	otherWildcard, hasOtherWildcard := other.values[tuple.PublicWildcard]
+	if hasOtherWildcard {
+		exclusion := NewSet[string](otherWildcard.GetExcludedSubjectIds()...)
+		for subjectID := range bss.values {
+			if subjectID != tuple.PublicWildcard {
+				if exclusion.Has(subjectID) {
+					delete(bss.values, subjectID)
+				}
+			}
+		}
+	}
+
+	// Remove any concrete subjects, if the other does not have a wildcard.
+	if !hasOtherWildcard {
+		for subjectID := range bss.values {
+			if subjectID != tuple.PublicWildcard {
+				if _, ok := other.values[subjectID]; !ok {
+					delete(bss.values, subjectID)
+				}
+			}
+		}
+	}
+
+	// Handle the case where the current set has a wildcard. We have to do two operations:
+	//
+	// 1) If the current set has a wildcard, either add the exclusions together if the other set
+	// also has a wildcard, or remove it if it did not.
+	//
+	// 2) We also add in any other set members that  are not in the wildcard's exclusion set, as
+	// an intersection between a wildcard with exclusions and concrete types will always return
+	// concrete types as well.
+	if wildcard, ok := bss.values[tuple.PublicWildcard]; ok {
+		exclusions := NewSet[string](wildcard.GetExcludedSubjectIds()...)
+
+		if hasOtherWildcard {
+			toBeExcluded := NewSet[string]()
+			toBeExcluded.Extend(wildcard.GetExcludedSubjectIds())
+			toBeExcluded.Extend(otherWildcard.GetExcludedSubjectIds())
+			bss.values[tuple.PublicWildcard] = bss.constructor(tuple.PublicWildcard, toBeExcluded.AsSlice(), wildcard, otherWildcard)
+		} else {
+			// Remove this wildcard.
+			delete(bss.values, tuple.PublicWildcard)
+		}
+
+		// Add any concrete items from the other set into this set. This is necebssary because an
+		// intersection between a wildcard and a concrete should always return that concrete, except
+		// if it is within the wildcard's exclusion list.
+		//
+		// As a concrete example, given `user:* - user:tom` and `user:sarah`, the first set contains
+		// all users except `tom` (and thus includes `sarah`) and the second is `sarah`, so the result
+		// must include `sarah`.
+		for subjectID, fs := range other.values {
+			if subjectID != tuple.PublicWildcard && !exclusions.Has(subjectID) {
+				bss.values[subjectID] = fs
+			}
+		}
+	}
+
+	// If a combiner is defined, run it over all values from both sets.
+	if bss.combiner != nil {
+		for subjectID := range bss.values {
+			if added, ok := other.values[subjectID]; ok {
+				bss.values[subjectID] = bss.combiner(bss.values[subjectID], added)
+			}
+		}
+	}
+}
+
+// UnionWith adds the given subjects to this set, via a union call.
+func (bss BaseSubjectSet[T]) UnionWith(foundSubjects []T) {
+	for _, fs := range foundSubjects {
+		bss.Add(fs)
+	}
+}
+
+// UnionWithSet performs a union operation between this set and the other set, modifying this
+// set in place.
+func (bss BaseSubjectSet[T]) UnionWithSet(other BaseSubjectSet[T]) {
+	bss.UnionWith(other.AsSlice())
+}
+
+// Get returns the found subject with the given ID in the set, if any.
+func (bss BaseSubjectSet[T]) Get(id string) (T, bool) {
+	found, ok := bss.values[id]
+	return found, ok
+}
+
+// IsEmpty returns whether the subject set is empty.
+func (bss BaseSubjectSet[T]) IsEmpty() bool {
+	return len(bss.values) == 0
+}
+
+// AsSlice returns the contents of the subject set as a slice of found subjects.
+func (bss BaseSubjectSet[T]) AsSlice() []T {
+	slice := make([]T, 0, len(bss.values))
+	for _, fs := range bss.values {
+		slice = append(slice, fs)
+	}
+	return slice
+}
+
+// Clone returns a clone of this subject set. Note that this is a shallow clone.
+// NOTE: Should only be used when performance is not a concern.
+func (bss BaseSubjectSet[T]) Clone() BaseSubjectSet[T] {
+	return BaseSubjectSet[T]{maps.Clone(bss.values), bss.constructor, bss.combiner}
+}
+
+// UnsafeRemoveExact removes the *exact* matching subject, with no wildcard handling.
+// This should ONLY be used for testing.
+func (bss BaseSubjectSet[T]) UnsafeRemoveExact(foundSubject T) {
+	delete(bss.values, foundSubject.GetSubjectId())
+}
diff --git a/internal/util/set.go b/internal/util/set.go
index 5c33966122..ee421b7d55 100644
--- a/internal/util/set.go
+++ b/internal/util/set.go
@@ -6,10 +6,14 @@ type Set[T comparable] struct {
 }
 
 // NewSet returns a new set.
-func NewSet[T comparable]() *Set[T] {
-	return &Set[T]{
+func NewSet[T comparable](items ...T) *Set[T] {
+	s := &Set[T]{
 		values: map[T]struct{}{},
 	}
+	for _, item := range items {
+		s.values[item] = struct{}{}
+	}
+	return s
 }
 
 // Has returns true if the set contains the given value.
@@ -48,13 +52,14 @@ func (s *Set[T]) Extend(values []T) {
 }
 
 // IntersectionDifference removes any values from this set that
-// are not shared with the other set.
-func (s *Set[T]) IntersectionDifference(other *Set[T]) {
+// are not shared with the other set. Returns the same set.
+func (s *Set[T]) IntersectionDifference(other *Set[T]) *Set[T] {
 	for value := range s.values {
 		if !other.Has(value) {
 			s.Remove(value)
 		}
 	}
+	return s
 }
 
 // RemoveAll removes all values from this set found in the other set.
@@ -64,6 +69,14 @@ func (s *Set[T]) RemoveAll(other *Set[T]) {
 	}
 }
 
+// Subtract subtracts the other set from this set, returning a new set.
+func (s *Set[T]) Subtract(other *Set[T]) *Set[T] {
+	newSet := NewSet[T]()
+	newSet.Extend(s.AsSlice())
+	newSet.RemoveAll(other)
+	return newSet
+}
+
 // IsEmpty returns true if the set is empty.
 func (s *Set[T]) IsEmpty() bool {
 	return len(s.values) == 0
@@ -71,6 +84,10 @@ func (s *Set[T]) IsEmpty() bool {
 
 // AsSlice returns the set as a slice of values.
 func (s *Set[T]) AsSlice() []T {
+	if len(s.values) == 0 {
+		return nil
+	}
+
 	slice := make([]T, 0, len(s.values))
 	for value := range s.values {
 		slice = append(slice, value)
diff --git a/internal/util/set_test.go b/internal/util/set_test.go
index f551a8862f..0472997416 100644
--- a/internal/util/set_test.go
+++ b/internal/util/set_test.go
@@ -89,7 +89,7 @@ func TestSetIntersectionDifference(t *testing.T) {
 		{
 			[]int{1, 3, 5, 7, 9},
 			[]int{2, 4, 6, 8, 10},
-			[]int{},
+			nil,
 		},
 		{
 			[]int{1, 2, 3, 4, 5},
diff --git a/internal/util/subjectset.go b/internal/util/subjectset.go
new file mode 100644
index 0000000000..922e23fc00
--- /dev/null
+++ b/internal/util/subjectset.go
@@ -0,0 +1,41 @@
+package util
+
+import (
+	v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1"
+)
+
+// SubjectSet defines a set that tracks accessible subjects.
+//
+// NOTE: Unlike a traditional set, unions between wildcards and a concrete subject will result
+// in *both* being present in the set, to maintain the proper set semantics around wildcards.
+type SubjectSet struct {
+	BaseSubjectSet[*v1.FoundSubject]
+}
+
+// NewSubjectSet creates and returns a new subject set.
+func NewSubjectSet() SubjectSet {
+	return SubjectSet{
+		BaseSubjectSet: BaseSubjectSet[*v1.FoundSubject]{
+			values: map[string]*v1.FoundSubject{},
+			constructor: func(subjectID string, excludedSubjectIDs []string, sources ...*v1.FoundSubject) *v1.FoundSubject {
+				return &v1.FoundSubject{
+					SubjectId:          subjectID,
+					ExcludedSubjectIds: excludedSubjectIDs,
+				}
+			},
+			combiner: nil,
+		},
+	}
+}
+
+func (ss SubjectSet) SubtractAll(other SubjectSet) {
+	ss.BaseSubjectSet.SubtractAll(other.BaseSubjectSet)
+}
+
+func (ss SubjectSet) IntersectionDifference(other SubjectSet) {
+	ss.BaseSubjectSet.IntersectionDifference(other.BaseSubjectSet)
+}
+
+func (ss SubjectSet) UnionWithSet(other SubjectSet) {
+	ss.BaseSubjectSet.UnionWithSet(other.BaseSubjectSet)
+}
diff --git a/internal/util/subjectset_test.go b/internal/util/subjectset_test.go
new file mode 100644
index 0000000000..917eb364f4
--- /dev/null
+++ b/internal/util/subjectset_test.go
@@ -0,0 +1,374 @@
+package util
+
+import (
+	"sort"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	v1 "github.com/authzed/spicedb/pkg/proto/dispatch/v1"
+	"github.com/authzed/spicedb/pkg/tuple"
+)
+
+func sub(subjectID string) *v1.FoundSubject {
+	return &v1.FoundSubject{
+		SubjectId: subjectID,
+	}
+}
+
+func wc(exclusions ...string) *v1.FoundSubject {
+	return &v1.FoundSubject{
+		SubjectId:          tuple.PublicWildcard,
+		ExcludedSubjectIds: exclusions,
+	}
+}
+
+func TestSubjectSetAdd(t *testing.T) {
+	tcs := []struct {
+		name           string
+		existing       []*v1.FoundSubject
+		toAdd          *v1.FoundSubject
+		expectedResult bool
+		expectedSet    []*v1.FoundSubject
+	}{
+		{
+			"basic add",
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+			sub("baz"),
+			true,
+			[]*v1.FoundSubject{sub("foo"), sub("bar"), sub("baz")},
+		},
+		{
+			"basic repeated add",
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+			sub("bar"),
+			false,
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+		},
+		{
+			"add of an empty wildcard",
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+			wc(),
+			true,
+			[]*v1.FoundSubject{sub("foo"), sub("bar"), wc()},
+		},
+		{
+			"add of a wildcard with exclusions",
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+			wc("1", "2"),
+			true,
+			[]*v1.FoundSubject{sub("foo"), sub("bar"), wc("1", "2")},
+		},
+		{
+			"add of a wildcard to a wildcard",
+			[]*v1.FoundSubject{sub("foo"), sub("bar"), wc()},
+			wc(),
+			false,
+			[]*v1.FoundSubject{sub("foo"), sub("bar"), wc()},
+		},
+		{
+			"add of a wildcard with exclusions to a bare wildcard",
+			[]*v1.FoundSubject{sub("foo"), sub("bar"), wc()},
+			wc("1", "2"),
+			false,
+			[]*v1.FoundSubject{sub("foo"), sub("bar"), wc()},
+		},
+		{
+			"add of a bare wildcard to a wildcard with exclusions",
+			[]*v1.FoundSubject{sub("foo"), sub("bar"), wc("1", "2")},
+			wc(),
+			false,
+			[]*v1.FoundSubject{sub("foo"), sub("bar"), wc()},
+		},
+		{
+			"add of a wildcard with exclusions to a wildcard with exclusions",
+			[]*v1.FoundSubject{sub("foo"), sub("bar"), wc("1", "2")},
+			wc("2", "3"),
+			false,
+			[]*v1.FoundSubject{sub("foo"), sub("bar"), wc("2")},
+		},
+		{
+			"add of a subject to a wildcard with exclusions that does not have that subject",
+			[]*v1.FoundSubject{wc("1", "2")},
+			sub("3"),
+			true,
+			[]*v1.FoundSubject{wc("1", "2"), sub("3")},
+		},
+		{
+			"add of a subject to a wildcard with exclusions that has that subject",
+			[]*v1.FoundSubject{wc("1", "2")},
+			sub("2"),
+			true,
+			[]*v1.FoundSubject{wc("1"), sub("2")},
+		},
+		{
+			"add of a subject to a bare wildcard",
+			[]*v1.FoundSubject{wc()},
+			sub("1"),
+			true,
+			[]*v1.FoundSubject{wc(), sub("1")},
+		},
+		{
+			"add of two wildcards",
+			[]*v1.FoundSubject{wc("1")},
+			wc("2"),
+			false,
+			[]*v1.FoundSubject{wc()},
+		},
+		{
+			"add of two wildcards with same restrictions",
+			[]*v1.FoundSubject{wc("1")},
+			wc("1", "2"),
+			false,
+			[]*v1.FoundSubject{wc("1")},
+		},
+	}
+
+	for _, tc := range tcs {
+		t.Run(tc.name, func(t *testing.T) {
+			existingSet := NewSubjectSet()
+			for _, existing := range tc.existing {
+				require.True(t, existingSet.Add(existing))
+			}
+
+			require.Equal(t, tc.expectedResult, existingSet.Add(tc.toAdd))
+
+			expectedSet := tc.expectedSet
+			computedSet := existingSet.AsSlice()
+
+			sort.Sort(sortByID(expectedSet))
+			sort.Sort(sortByID(computedSet))
+
+			stableSortExclusions(expectedSet)
+			stableSortExclusions(computedSet)
+
+			require.Equal(t, expectedSet, computedSet)
+		})
+	}
+}
+
+func TestSubjectSetSubtract(t *testing.T) {
+	tcs := []struct {
+		name        string
+		existing    []*v1.FoundSubject
+		toSubtract  *v1.FoundSubject
+		expectedSet []*v1.FoundSubject
+	}{
+		{
+			"basic subtract, no overlap",
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+			sub("baz"),
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+		},
+		{
+			"basic subtract, with overlap",
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+			sub("bar"),
+			[]*v1.FoundSubject{sub("foo")},
+		},
+		{
+			"basic subtract from bare wildcard",
+			[]*v1.FoundSubject{sub("foo"), wc()},
+			sub("bar"),
+			[]*v1.FoundSubject{sub("foo"), wc("bar")},
+		},
+		{
+			"subtract from bare wildcard and set",
+			[]*v1.FoundSubject{sub("bar"), wc()},
+			sub("bar"),
+			[]*v1.FoundSubject{wc("bar")},
+		},
+		{
+			"subtract from wildcard",
+			[]*v1.FoundSubject{sub("bar"), wc("bar")},
+			sub("bar"),
+			[]*v1.FoundSubject{wc("bar")},
+		},
+		{
+			"subtract from wildcard with existing exclusions",
+			[]*v1.FoundSubject{sub("bar"), wc("hiya")},
+			sub("bar"),
+			[]*v1.FoundSubject{wc("bar", "hiya")},
+		},
+		{
+			"subtract bare wildcard from set",
+			[]*v1.FoundSubject{sub("bar"), sub("foo")},
+			wc(),
+			[]*v1.FoundSubject{},
+		},
+		{
+			"subtract wildcard with no matching exclusions from set",
+			[]*v1.FoundSubject{sub("bar"), sub("foo")},
+			wc("baz"),
+			[]*v1.FoundSubject{},
+		},
+		{
+			"subtract wildcard with matching exclusions from set",
+			[]*v1.FoundSubject{sub("bar"), sub("foo")},
+			wc("bar"),
+			[]*v1.FoundSubject{sub("bar")},
+		},
+		{
+			"subtract wildcard from another wildcard, both with the same exclusions",
+			[]*v1.FoundSubject{wc("sarah")},
+			wc("sarah"),
+			[]*v1.FoundSubject{},
+		},
+		{
+			"subtract wildcard from another wildcard, with different exclusions",
+			[]*v1.FoundSubject{wc("tom"), sub("foo"), sub("bar")},
+			wc("sarah"),
+			[]*v1.FoundSubject{sub("sarah")},
+		},
+		{
+			"subtract wildcard from another wildcard, with more exclusions",
+			[]*v1.FoundSubject{wc("sarah")},
+			wc("sarah", "tom"),
+			[]*v1.FoundSubject{sub("tom")},
+		},
+	}
+
+	for _, tc := range tcs {
+		t.Run(tc.name, func(t *testing.T) {
+			existingSet := NewSubjectSet()
+			for _, existing := range tc.existing {
+				require.True(t, existingSet.Add(existing))
+			}
+
+			existingSet.Subtract(tc.toSubtract)
+
+			expectedSet := tc.expectedSet
+			computedSet := existingSet.AsSlice()
+
+			sort.Sort(sortByID(expectedSet))
+			sort.Sort(sortByID(computedSet))
+
+			stableSortExclusions(expectedSet)
+			stableSortExclusions(computedSet)
+
+			require.Equal(t, expectedSet, computedSet)
+		})
+	}
+}
+
+func TestSubjectSetIntersection(t *testing.T) {
+	tcs := []struct {
+		name        string
+		existing    []*v1.FoundSubject
+		toIntersect []*v1.FoundSubject
+		expectedSet []*v1.FoundSubject
+	}{
+		{
+			"basic intersection, full overlap",
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+		},
+		{
+			"basic intersection, partial overlap",
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+			[]*v1.FoundSubject{sub("foo")},
+			[]*v1.FoundSubject{sub("foo")},
+		},
+		{
+			"basic intersection, no overlap",
+			[]*v1.FoundSubject{sub("foo"), sub("bar")},
+			[]*v1.FoundSubject{sub("baz")},
+			[]*v1.FoundSubject{},
+		},
+		{
+			"intersection between bare wildcard and concrete",
+			[]*v1.FoundSubject{sub("foo")},
+			[]*v1.FoundSubject{wc()},
+			[]*v1.FoundSubject{sub("foo")},
+		},
+		{
+			"intersection between wildcard with exclusions and concrete",
+			[]*v1.FoundSubject{sub("foo")},
+			[]*v1.FoundSubject{wc("tom")},
+			[]*v1.FoundSubject{sub("foo")},
+		},
+		{
+			"intersection between wildcard with matching exclusions and concrete",
+			[]*v1.FoundSubject{sub("foo")},
+			[]*v1.FoundSubject{wc("foo")},
+			[]*v1.FoundSubject{},
+		},
+		{
+			"intersection between bare wildcards",
+			[]*v1.FoundSubject{wc()},
+			[]*v1.FoundSubject{wc()},
+			[]*v1.FoundSubject{wc()},
+		},
+		{
+			"intersection between bare wildcard and one with exclusions",
+			[]*v1.FoundSubject{wc()},
+			[]*v1.FoundSubject{wc("1", "2")},
+			[]*v1.FoundSubject{wc("1", "2")},
+		},
+		{
+			"intersection between wildcards",
+			[]*v1.FoundSubject{wc("2", "3")},
+			[]*v1.FoundSubject{wc("1", "2")},
+			[]*v1.FoundSubject{wc("1", "2", "3")},
+		},
+		{
+			"intersection wildcard with exclusions and concrete",
+			[]*v1.FoundSubject{wc("2", "3")},
+			[]*v1.FoundSubject{sub("4")},
+			[]*v1.FoundSubject{sub("4")},
+		},
+		{
+			"intersection wildcard with matching exclusions and concrete",
+			[]*v1.FoundSubject{wc("2", "3", "4")},
+			[]*v1.FoundSubject{sub("4")},
+			[]*v1.FoundSubject{},
+		},
+		{
+			"intersection of wildcards and two concrete types",
+			[]*v1.FoundSubject{wc(), sub("1")},
+			[]*v1.FoundSubject{wc(), sub("2")},
+			[]*v1.FoundSubject{wc(), sub("1"), sub("2")},
+		},
+	}
+
+	for _, tc := range tcs {
+		t.Run(tc.name, func(t *testing.T) {
+			existingSet := NewSubjectSet()
+			for _, existing := range tc.existing {
+				require.True(t, existingSet.Add(existing))
+			}
+
+			toIntersect := NewSubjectSet()
+			for _, toAdd := range tc.toIntersect {
+				require.True(t, toIntersect.Add(toAdd))
+			}
+
+			existingSet.IntersectionDifference(toIntersect)
+
+			expectedSet := tc.expectedSet
+			computedSet := existingSet.AsSlice()
+
+			sort.Sort(sortByID(expectedSet))
+			sort.Sort(sortByID(computedSet))
+
+			stableSortExclusions(expectedSet)
+			stableSortExclusions(computedSet)
+
+			require.Equal(t, expectedSet, computedSet)
+		})
+	}
+}
+
+type sortByID []*v1.FoundSubject
+
+func (a sortByID) Len() int           { return len(a) }
+func (a sortByID) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
+func (a sortByID) Less(i, j int) bool { return strings.Compare(a[i].SubjectId, a[j].SubjectId) < 0 }
+
+func stableSortExclusions(fss []*v1.FoundSubject) {
+	for _, fs := range fss {
+		sort.Strings(fs.ExcludedSubjectIds)
+	}
+}
diff --git a/pkg/proto/core/v1/core.pb.go b/pkg/proto/core/v1/core.pb.go
index 1f1fd731b9..f4d5822bca 100644
--- a/pkg/proto/core/v1/core.pb.go
+++ b/pkg/proto/core/v1/core.pb.go
@@ -129,15 +129,12 @@ func (SetOperationUserset_Operation) EnumDescriptor() ([]byte, []int) {
 type ReachabilityEntrypoint_ReachabilityEntrypointKind int32
 
 const (
-	//
 	// RELATION_ENTRYPOINT indicates an entrypoint where the subject object can be directly
 	// found for a relationship.
 	ReachabilityEntrypoint_RELATION_ENTRYPOINT ReachabilityEntrypoint_ReachabilityEntrypointKind = 0
-	//
 	// COMPUTED_USERSET_ENTRYPOINT indicates an entrypoint where the subject's relation is
 	// "rewritten" via a `computed_userset` to the target permission's operation node.
 	ReachabilityEntrypoint_COMPUTED_USERSET_ENTRYPOINT ReachabilityEntrypoint_ReachabilityEntrypointKind = 1
-	//
 	// TUPLESET_TO_USERSET_ENTRYPOINT indicates an entrypoint where the subject's relation is
 	// walked via a `tupleset_to_userset` in the target permission's operation node.
 	ReachabilityEntrypoint_TUPLESET_TO_USERSET_ENTRYPOINT ReachabilityEntrypoint_ReachabilityEntrypointKind = 2
@@ -187,12 +184,10 @@ func (ReachabilityEntrypoint_ReachabilityEntrypointKind) EnumDescriptor() ([]byt
 type ReachabilityEntrypoint_EntrypointResultStatus int32
 
 const (
-	//
 	// REACHABLE_CONDITIONAL_RESULT indicates that the entrypoint is under one or more intersections
 	// or exclusion operations, indicating that any reachable object *may* be a result, conditional
 	// on the parent non-union operation(s).
 	ReachabilityEntrypoint_REACHABLE_CONDITIONAL_RESULT ReachabilityEntrypoint_EntrypointResultStatus = 0
-	//
 	// DIRECT_OPERATION_RESULT indicates that the entrypoint exists solely under zero or more
 	// union operations, making any reachable object also a *result* of the relation or permission.
 	ReachabilityEntrypoint_DIRECT_OPERATION_RESULT ReachabilityEntrypoint_EntrypointResultStatus = 1
@@ -571,6 +566,7 @@ type RelationTupleTreeNode struct {
 	unknownFields protoimpl.UnknownFields
 
 	// Types that are assignable to NodeType:
+	//
 	//	*RelationTupleTreeNode_IntermediateNode
 	//	*RelationTupleTreeNode_LeafNode
 	NodeType isRelationTupleTreeNode_NodeType `protobuf_oneof:"node_type"`
@@ -755,7 +751,6 @@ func (x *DirectSubjects) GetSubjects() []*ObjectAndRelation {
 	return nil
 }
 
-//
 // Metadata is compiler metadata added to namespace definitions, such as doc comments and
 // relation kinds.
 type Metadata struct {
@@ -805,7 +800,6 @@ func (x *Metadata) GetMetadataMessage() []*anypb.Any {
 	return nil
 }
 
-//
 // NamespaceDefinition represents a single definition of an object type
 type NamespaceDefinition struct {
 	state         protoimpl.MessageState
@@ -882,7 +876,6 @@ func (x *NamespaceDefinition) GetSourcePosition() *SourcePosition {
 	return nil
 }
 
-//
 // Relation represents the definition of a relation or permission under a namespace.
 type Relation struct {
 	state         protoimpl.MessageState
@@ -893,7 +886,6 @@ type Relation struct {
 	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
 	// userset_rewrite, if specified, is the rewrite for computing the value of the permission.
 	UsersetRewrite *UsersetRewrite `protobuf:"bytes,2,opt,name=userset_rewrite,json=usersetRewrite,proto3" json:"userset_rewrite,omitempty"`
-	//
 	// type_information, if specified, is the list of allowed object types that can appear in this
 	// relation
 	TypeInformation *TypeInformation `protobuf:"bytes,3,opt,name=type_information,json=typeInformation,proto3" json:"type_information,omitempty"`
@@ -986,7 +978,6 @@ func (x *Relation) GetCanonicalCacheKey() string {
 	return ""
 }
 
-//
 // ReachabilityGraph is a serialized form of a reachability graph, representing how a relation can
 // be reached from one or more subject types.
 //
@@ -995,37 +986,37 @@ func (x *Relation) GetCanonicalCacheKey() string {
 //
 // For example, given the schema:
 // ```
-//   definition user {}
 //
-//   definition organization {
-//     relation admin: user
-//   }
+//	definition user {}
+//
+//	definition organization {
+//	  relation admin: user
+//	}
+//
+//	definition resource {
+//	  relation org: organization
+//	  relation viewer: user
+//	  relation owner: user
+//	  permission view = viewer + owner + org->admin
+//	}
 //
-//   definition resource {
-//     relation org: organization
-//     relation viewer: user
-//     relation owner: user
-//     permission view = viewer + owner + org->admin
-//   }
 // ```
 //
 // The reachability graph for `viewer` and the other relations will have entrypoints for each
 // subject type found for those relations.
 //
 // The full reachability graph for the `view` relation will have three entrypoints, representing:
-//   1) resource#viewer (computed_userset)
-//   2) resource#owner  (computed_userset)
-//   3) organization#admin (tupleset_to_userset)
+//  1. resource#viewer (computed_userset)
+//  2. resource#owner  (computed_userset)
+//  3. organization#admin (tupleset_to_userset)
 type ReachabilityGraph struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
-	//
 	// entrypoints_by_subject_type provides all entrypoints by subject *type*, representing wildcards.
 	// The keys of the map are the full path(s) for the namespace(s) referenced by reachable wildcards
 	EntrypointsBySubjectType map[string]*ReachabilityEntrypoints `protobuf:"bytes,1,rep,name=entrypoints_by_subject_type,json=entrypointsBySubjectType,proto3" json:"entrypoints_by_subject_type,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
-	//
 	// entrypoints_by_subject_relation provides all entrypoints by subject type+relation.
 	// The keys of the map are of the form `namespace_path#relation_name`
 	EntrypointsBySubjectRelation map[string]*ReachabilityEntrypoints `protobuf:"bytes,2,rep,name=entrypoints_by_subject_relation,json=entrypointsBySubjectRelation,proto3" json:"entrypoints_by_subject_relation,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
@@ -1077,7 +1068,6 @@ func (x *ReachabilityGraph) GetEntrypointsBySubjectRelation() map[string]*Reacha
 	return nil
 }
 
-//
 // ReachabilityEntrypoints represents all the entrypoints for a specific subject type or subject
 // relation into the reachability graph for a particular target relation.
 type ReachabilityEntrypoints struct {
@@ -1085,14 +1075,11 @@ type ReachabilityEntrypoints struct {
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
-	//
 	// entrypoints are the entrypoints found.
 	Entrypoints []*ReachabilityEntrypoint `protobuf:"bytes,1,rep,name=entrypoints,proto3" json:"entrypoints,omitempty"`
-	//
 	// subject_type, if specified, is the type of subjects to which the entrypoint(s) apply. A
 	// subject type is only set for wildcards.
 	SubjectType string `protobuf:"bytes,2,opt,name=subject_type,json=subjectType,proto3" json:"subject_type,omitempty"`
-	//
 	// subject_relation, if specified, is the type and relation of subjects to which the
 	// entrypoint(s) apply.
 	SubjectRelation *RelationReference `protobuf:"bytes,3,opt,name=subject_relation,json=subjectRelation,proto3" json:"subject_relation,omitempty"`
@@ -1151,7 +1138,6 @@ func (x *ReachabilityEntrypoints) GetSubjectRelation() *RelationReference {
 	return nil
 }
 
-//
 // ReachabilityEntrypoint represents a single entrypoint for a specific subject type or subject
 // relation into the reachability graph for a particular target relation.
 type ReachabilityEntrypoint struct {
@@ -1159,17 +1145,13 @@ type ReachabilityEntrypoint struct {
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
-	//
 	// kind is the kind of the entrypoint.
 	Kind ReachabilityEntrypoint_ReachabilityEntrypointKind `protobuf:"varint,1,opt,name=kind,proto3,enum=core.v1.ReachabilityEntrypoint_ReachabilityEntrypointKind" json:"kind,omitempty"`
-	//
 	// target_relation is the relation on which the entrypoint exists.
 	TargetRelation *RelationReference `protobuf:"bytes,2,opt,name=target_relation,json=targetRelation,proto3" json:"target_relation,omitempty"`
-	//
 	// result_status contains the status of objects found for this entrypoint as direct results for
 	// the parent relation/permission.
 	ResultStatus ReachabilityEntrypoint_EntrypointResultStatus `protobuf:"varint,4,opt,name=result_status,json=resultStatus,proto3,enum=core.v1.ReachabilityEntrypoint_EntrypointResultStatus" json:"result_status,omitempty"`
-	//
 	// tupleset_relation is the name of the tupleset relation on the TupleToUserset this entrypoint
 	// represents, if applicable.
 	TuplesetRelation string `protobuf:"bytes,5,opt,name=tupleset_relation,json=tuplesetRelation,proto3" json:"tupleset_relation,omitempty"`
@@ -1235,14 +1217,12 @@ func (x *ReachabilityEntrypoint) GetTuplesetRelation() string {
 	return ""
 }
 
-//
 // TypeInformation defines the allowed types for a relation.
 type TypeInformation struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
-	//
 	// allowed_direct_relations are those relation types allowed to be placed into a relation,
 	// e.g. the types of subjects allowed when a relationship is written to the relation
 	AllowedDirectRelations []*AllowedRelation `protobuf:"bytes,1,rep,name=allowed_direct_relations,json=allowedDirectRelations,proto3" json:"allowed_direct_relations,omitempty"`
@@ -1287,7 +1267,6 @@ func (x *TypeInformation) GetAllowedDirectRelations() []*AllowedRelation {
 	return nil
 }
 
-//
 // AllowedRelation is an allowed type of a relation when used as a subject.
 type AllowedRelation struct {
 	state         protoimpl.MessageState
@@ -1296,10 +1275,10 @@ type AllowedRelation struct {
 
 	// namespace is the full namespace path of the allowed object type
 	Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"`
-	//
 	// relation_or_wildcard indicates the relation for the objects, or a wildcard.
 	//
 	// Types that are assignable to RelationOrWildcard:
+	//
 	//	*AllowedRelation_Relation
 	//	*AllowedRelation_PublicWildcard_
 	RelationOrWildcard isAllowedRelation_RelationOrWildcard `protobuf_oneof:"relation_or_wildcard"`
@@ -1396,6 +1375,7 @@ type UsersetRewrite struct {
 	unknownFields protoimpl.UnknownFields
 
 	// Types that are assignable to RewriteOperation:
+	//
 	//	*UsersetRewrite_Union
 	//	*UsersetRewrite_Intersection
 	//	*UsersetRewrite_Exclusion
@@ -1764,6 +1744,7 @@ type SetOperation_Child struct {
 	unknownFields protoimpl.UnknownFields
 
 	// Types that are assignable to ChildType:
+	//
 	//	*SetOperation_Child_XThis
 	//	*SetOperation_Child_ComputedUserset
 	//	*SetOperation_Child_TupleToUserset
@@ -1771,7 +1752,6 @@ type SetOperation_Child struct {
 	//	*SetOperation_Child_XNil
 	ChildType      isSetOperation_Child_ChildType `protobuf_oneof:"child_type"`
 	SourcePosition *SourcePosition                `protobuf:"bytes,5,opt,name=source_position,json=sourcePosition,proto3" json:"source_position,omitempty"`
-	//
 	// operation_path (if specified) is the *unique* ID for the set operation in the permission
 	// definition. It is a heirarchy representing the position of the operation under its parent
 	// operation. For example, the operation path of an operation which is the third child of the
diff --git a/pkg/proto/dispatch/v1/dispatch.pb.go b/pkg/proto/dispatch/v1/dispatch.pb.go
index 042b91558f..981f8249f4 100644
--- a/pkg/proto/dispatch/v1/dispatch.pb.go
+++ b/pkg/proto/dispatch/v1/dispatch.pb.go
@@ -166,11 +166,9 @@ func (DispatchExpandRequest_ExpansionMode) EnumDescriptor() ([]byte, []int) {
 type ReachableResource_ResultStatus int32
 
 const (
-	//
 	// REQUIRES_CHECK indicates that the resource is reachable but a Check is required to
 	// determine if the resource is actually found for the user.
 	ReachableResource_REQUIRES_CHECK ReachableResource_ResultStatus = 0
-	//
 	// HAS_PERMISSION indicates that the resource is both reachable and found for the permission
 	// for the subject.
 	ReachableResource_HAS_PERMISSION ReachableResource_ResultStatus = 1
@@ -261,7 +259,7 @@ func (x CheckDebugTrace_RelationType) Number() protoreflect.EnumNumber {
 
 // Deprecated: Use CheckDebugTrace_RelationType.Descriptor instead.
 func (CheckDebugTrace_RelationType) EnumDescriptor() ([]byte, []int) {
-	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{14, 0}
+	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{15, 0}
 }
 
 type DispatchCheckRequest struct {
@@ -910,19 +908,74 @@ func (x *DispatchLookupSubjectsRequest) GetSubjectRelation() *v1.RelationReferen
 	return nil
 }
 
+type FoundSubject struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	SubjectId          string   `protobuf:"bytes,1,opt,name=subject_id,json=subjectId,proto3" json:"subject_id,omitempty"`
+	ExcludedSubjectIds []string `protobuf:"bytes,2,rep,name=excluded_subject_ids,json=excludedSubjectIds,proto3" json:"excluded_subject_ids,omitempty"`
+}
+
+func (x *FoundSubject) Reset() {
+	*x = FoundSubject{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_dispatch_v1_dispatch_proto_msgTypes[10]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *FoundSubject) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FoundSubject) ProtoMessage() {}
+
+func (x *FoundSubject) ProtoReflect() protoreflect.Message {
+	mi := &file_dispatch_v1_dispatch_proto_msgTypes[10]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use FoundSubject.ProtoReflect.Descriptor instead.
+func (*FoundSubject) Descriptor() ([]byte, []int) {
+	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *FoundSubject) GetSubjectId() string {
+	if x != nil {
+		return x.SubjectId
+	}
+	return ""
+}
+
+func (x *FoundSubject) GetExcludedSubjectIds() []string {
+	if x != nil {
+		return x.ExcludedSubjectIds
+	}
+	return nil
+}
+
 type DispatchLookupSubjectsResponse struct {
 	state         protoimpl.MessageState
 	sizeCache     protoimpl.SizeCache
 	unknownFields protoimpl.UnknownFields
 
-	FoundSubjectIds []string      `protobuf:"bytes,1,rep,name=found_subject_ids,json=foundSubjectIds,proto3" json:"found_subject_ids,omitempty"`
-	Metadata        *ResponseMeta `protobuf:"bytes,2,opt,name=metadata,proto3" json:"metadata,omitempty"`
+	FoundSubjects []*FoundSubject `protobuf:"bytes,1,rep,name=found_subjects,json=foundSubjects,proto3" json:"found_subjects,omitempty"`
+	Metadata      *ResponseMeta   `protobuf:"bytes,2,opt,name=metadata,proto3" json:"metadata,omitempty"`
 }
 
 func (x *DispatchLookupSubjectsResponse) Reset() {
 	*x = DispatchLookupSubjectsResponse{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_dispatch_v1_dispatch_proto_msgTypes[10]
+		mi := &file_dispatch_v1_dispatch_proto_msgTypes[11]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -935,7 +988,7 @@ func (x *DispatchLookupSubjectsResponse) String() string {
 func (*DispatchLookupSubjectsResponse) ProtoMessage() {}
 
 func (x *DispatchLookupSubjectsResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_dispatch_v1_dispatch_proto_msgTypes[10]
+	mi := &file_dispatch_v1_dispatch_proto_msgTypes[11]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -948,12 +1001,12 @@ func (x *DispatchLookupSubjectsResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use DispatchLookupSubjectsResponse.ProtoReflect.Descriptor instead.
 func (*DispatchLookupSubjectsResponse) Descriptor() ([]byte, []int) {
-	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{10}
+	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{11}
 }
 
-func (x *DispatchLookupSubjectsResponse) GetFoundSubjectIds() []string {
+func (x *DispatchLookupSubjectsResponse) GetFoundSubjects() []*FoundSubject {
 	if x != nil {
-		return x.FoundSubjectIds
+		return x.FoundSubjects
 	}
 	return nil
 }
@@ -977,7 +1030,7 @@ type ResolverMeta struct {
 func (x *ResolverMeta) Reset() {
 	*x = ResolverMeta{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_dispatch_v1_dispatch_proto_msgTypes[11]
+		mi := &file_dispatch_v1_dispatch_proto_msgTypes[12]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -990,7 +1043,7 @@ func (x *ResolverMeta) String() string {
 func (*ResolverMeta) ProtoMessage() {}
 
 func (x *ResolverMeta) ProtoReflect() protoreflect.Message {
-	mi := &file_dispatch_v1_dispatch_proto_msgTypes[11]
+	mi := &file_dispatch_v1_dispatch_proto_msgTypes[12]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1003,7 +1056,7 @@ func (x *ResolverMeta) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ResolverMeta.ProtoReflect.Descriptor instead.
 func (*ResolverMeta) Descriptor() ([]byte, []int) {
-	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{11}
+	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{12}
 }
 
 func (x *ResolverMeta) GetAtRevision() string {
@@ -1034,7 +1087,7 @@ type ResponseMeta struct {
 func (x *ResponseMeta) Reset() {
 	*x = ResponseMeta{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_dispatch_v1_dispatch_proto_msgTypes[12]
+		mi := &file_dispatch_v1_dispatch_proto_msgTypes[13]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1047,7 +1100,7 @@ func (x *ResponseMeta) String() string {
 func (*ResponseMeta) ProtoMessage() {}
 
 func (x *ResponseMeta) ProtoReflect() protoreflect.Message {
-	mi := &file_dispatch_v1_dispatch_proto_msgTypes[12]
+	mi := &file_dispatch_v1_dispatch_proto_msgTypes[13]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1060,7 +1113,7 @@ func (x *ResponseMeta) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use ResponseMeta.ProtoReflect.Descriptor instead.
 func (*ResponseMeta) Descriptor() ([]byte, []int) {
-	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{12}
+	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{13}
 }
 
 func (x *ResponseMeta) GetDispatchCount() uint32 {
@@ -1102,7 +1155,7 @@ type DebugInformation struct {
 func (x *DebugInformation) Reset() {
 	*x = DebugInformation{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_dispatch_v1_dispatch_proto_msgTypes[13]
+		mi := &file_dispatch_v1_dispatch_proto_msgTypes[14]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1115,7 +1168,7 @@ func (x *DebugInformation) String() string {
 func (*DebugInformation) ProtoMessage() {}
 
 func (x *DebugInformation) ProtoReflect() protoreflect.Message {
-	mi := &file_dispatch_v1_dispatch_proto_msgTypes[13]
+	mi := &file_dispatch_v1_dispatch_proto_msgTypes[14]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1128,7 +1181,7 @@ func (x *DebugInformation) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use DebugInformation.ProtoReflect.Descriptor instead.
 func (*DebugInformation) Descriptor() ([]byte, []int) {
-	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{13}
+	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{14}
 }
 
 func (x *DebugInformation) GetCheck() *CheckDebugTrace {
@@ -1153,7 +1206,7 @@ type CheckDebugTrace struct {
 func (x *CheckDebugTrace) Reset() {
 	*x = CheckDebugTrace{}
 	if protoimpl.UnsafeEnabled {
-		mi := &file_dispatch_v1_dispatch_proto_msgTypes[14]
+		mi := &file_dispatch_v1_dispatch_proto_msgTypes[15]
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms.StoreMessageInfo(mi)
 	}
@@ -1166,7 +1219,7 @@ func (x *CheckDebugTrace) String() string {
 func (*CheckDebugTrace) ProtoMessage() {}
 
 func (x *CheckDebugTrace) ProtoReflect() protoreflect.Message {
-	mi := &file_dispatch_v1_dispatch_proto_msgTypes[14]
+	mi := &file_dispatch_v1_dispatch_proto_msgTypes[15]
 	if protoimpl.UnsafeEnabled && x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -1179,7 +1232,7 @@ func (x *CheckDebugTrace) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use CheckDebugTrace.ProtoReflect.Descriptor instead.
 func (*CheckDebugTrace) Descriptor() ([]byte, []int) {
-	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{14}
+	return file_dispatch_v1_dispatch_proto_rawDescGZIP(), []int{15}
 }
 
 func (x *CheckDebugTrace) GetRequest() *DispatchCheckRequest {
@@ -1385,111 +1438,118 @@ var file_dispatch_v1_dispatch_proto_rawDesc = []byte{
 	0x6f, 0x72, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52,
 	0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x42, 0x08, 0xfa, 0x42, 0x05, 0x8a, 0x01, 0x02,
 	0x10, 0x01, 0x52, 0x0f, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x6c, 0x61, 0x74,
-	0x69, 0x6f, 0x6e, 0x22, 0x83, 0x01, 0x0a, 0x1e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68,
-	0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65,
-	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x11, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x5f,
-	0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
-	0x09, 0x52, 0x0f, 0x66, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x49,
-	0x64, 0x73, 0x12, 0x35, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02,
-	0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e,
-	0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x52,
-	0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0x7d, 0x0a, 0x0c, 0x52, 0x65, 0x73,
-	0x6f, 0x6c, 0x76, 0x65, 0x72, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x3b, 0x0a, 0x0b, 0x61, 0x74, 0x5f,
-	0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1a,
-	0xfa, 0x42, 0x17, 0x72, 0x15, 0x32, 0x13, 0x5e, 0x5b, 0x30, 0x2d, 0x39, 0x5d, 0x2b, 0x28, 0x5c,
-	0x2e, 0x5b, 0x30, 0x2d, 0x39, 0x5d, 0x2b, 0x29, 0x3f, 0x24, 0x52, 0x0a, 0x61, 0x74, 0x52, 0x65,
-	0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x0f, 0x64, 0x65, 0x70, 0x74, 0x68, 0x5f,
-	0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x42,
-	0x07, 0xfa, 0x42, 0x04, 0x2a, 0x02, 0x20, 0x00, 0x52, 0x0e, 0x64, 0x65, 0x70, 0x74, 0x68, 0x52,
-	0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x22, 0xda, 0x01, 0x0a, 0x0c, 0x52, 0x65, 0x73,
-	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x25, 0x0a, 0x0e, 0x64, 0x69, 0x73,
-	0x70, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28,
-	0x0d, 0x52, 0x0d, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, 0x6f, 0x75, 0x6e, 0x74,
-	0x12, 0x25, 0x0a, 0x0e, 0x64, 0x65, 0x70, 0x74, 0x68, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72,
-	0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x52,
-	0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x12, 0x32, 0x0a, 0x15, 0x63, 0x61, 0x63, 0x68, 0x65,
-	0x64, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74,
-	0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x13, 0x63, 0x61, 0x63, 0x68, 0x65, 0x64, 0x44, 0x69,
-	0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x3c, 0x0a, 0x0a, 0x64,
-	0x65, 0x62, 0x75, 0x67, 0x5f, 0x69, 0x6e, 0x66, 0x6f, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32,
-	0x1d, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65,
-	0x62, 0x75, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09,
-	0x64, 0x65, 0x62, 0x75, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x4a,
-	0x04, 0x08, 0x05, 0x10, 0x06, 0x22, 0x46, 0x0a, 0x10, 0x44, 0x65, 0x62, 0x75, 0x67, 0x49, 0x6e,
-	0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x32, 0x0a, 0x05, 0x63, 0x68, 0x65,
-	0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61,
-	0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x44, 0x65, 0x62, 0x75,
-	0x67, 0x54, 0x72, 0x61, 0x63, 0x65, 0x52, 0x05, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x22, 0xfc, 0x02,
-	0x0a, 0x0f, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63,
-	0x65, 0x12, 0x3b, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01,
-	0x28, 0x0b, 0x32, 0x21, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31,
-	0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65,
-	0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x5f,
-	0x0a, 0x16, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74,
-	0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x29,
-	0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65,
-	0x63, 0x6b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x6c,
-	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x14, 0x72, 0x65, 0x73, 0x6f, 0x75,
-	0x72, 0x63, 0x65, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12,
-	0x25, 0x0a, 0x0e, 0x68, 0x61, 0x73, 0x5f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f,
-	0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x68, 0x61, 0x73, 0x50, 0x65, 0x72, 0x6d,
-	0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x28, 0x0a, 0x10, 0x69, 0x73, 0x5f, 0x63, 0x61, 0x63,
-	0x68, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08,
-	0x52, 0x0e, 0x69, 0x73, 0x43, 0x61, 0x63, 0x68, 0x65, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74,
-	0x12, 0x3f, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x5f, 0x70, 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x73,
-	0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63,
-	0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x54,
-	0x72, 0x61, 0x63, 0x65, 0x52, 0x0b, 0x73, 0x75, 0x62, 0x50, 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d,
-	0x73, 0x22, 0x39, 0x0a, 0x0c, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70,
-	0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0c,
-	0x0a, 0x08, 0x52, 0x45, 0x4c, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a,
-	0x50, 0x45, 0x52, 0x4d, 0x49, 0x53, 0x53, 0x49, 0x4f, 0x4e, 0x10, 0x02, 0x32, 0xa0, 0x04, 0x0a,
-	0x0f, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
-	0x12, 0x58, 0x0a, 0x0d, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63,
-	0x6b, 0x12, 0x21, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e,
-	0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71,
-	0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e,
-	0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b,
-	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x5b, 0x0a, 0x0e, 0x44, 0x69,
-	0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, 0x70, 0x61, 0x6e, 0x64, 0x12, 0x22, 0x2e, 0x64,
-	0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61,
-	0x74, 0x63, 0x68, 0x45, 0x78, 0x70, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
-	0x1a, 0x23, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44,
-	0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, 0x70, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73,
-	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x5b, 0x0a, 0x0e, 0x44, 0x69, 0x73, 0x70, 0x61,
-	0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x12, 0x22, 0x2e, 0x64, 0x69, 0x73, 0x70,
+	0x69, 0x6f, 0x6e, 0x22, 0x5f, 0x0a, 0x0c, 0x46, 0x6f, 0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a,
+	0x65, 0x63, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x69,
+	0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74,
+	0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x73,
+	0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09,
+	0x52, 0x12, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63,
+	0x74, 0x49, 0x64, 0x73, 0x22, 0x99, 0x01, 0x0a, 0x1e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63,
+	0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52,
+	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0e, 0x66, 0x6f, 0x75, 0x6e, 0x64,
+	0x5f, 0x73, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32,
+	0x19, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x6f,
+	0x75, 0x6e, 0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x0d, 0x66, 0x6f, 0x75, 0x6e,
+	0x64, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x35, 0x0a, 0x08, 0x6d, 0x65, 0x74,
+	0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x64, 0x69,
+	0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+	0x73, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
+	0x22, 0x7d, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x6f, 0x6c, 0x76, 0x65, 0x72, 0x4d, 0x65, 0x74, 0x61,
+	0x12, 0x3b, 0x0a, 0x0b, 0x61, 0x74, 0x5f, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1a, 0xfa, 0x42, 0x17, 0x72, 0x15, 0x32, 0x13, 0x5e, 0x5b,
+	0x30, 0x2d, 0x39, 0x5d, 0x2b, 0x28, 0x5c, 0x2e, 0x5b, 0x30, 0x2d, 0x39, 0x5d, 0x2b, 0x29, 0x3f,
+	0x24, 0x52, 0x0a, 0x61, 0x74, 0x52, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a,
+	0x0f, 0x64, 0x65, 0x70, 0x74, 0x68, 0x5f, 0x72, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x42, 0x07, 0xfa, 0x42, 0x04, 0x2a, 0x02, 0x20, 0x00, 0x52,
+	0x0e, 0x64, 0x65, 0x70, 0x74, 0x68, 0x52, 0x65, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x67, 0x22,
+	0xda, 0x01, 0x0a, 0x0c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x4d, 0x65, 0x74, 0x61,
+	0x12, 0x25, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x63, 0x6f, 0x75,
+	0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0d, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74,
+	0x63, 0x68, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x64, 0x65, 0x70, 0x74, 0x68,
+	0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52,
+	0x0d, 0x64, 0x65, 0x70, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x12, 0x32,
+	0x0a, 0x15, 0x63, 0x61, 0x63, 0x68, 0x65, 0x64, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63,
+	0x68, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x13, 0x63,
+	0x61, 0x63, 0x68, 0x65, 0x64, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43, 0x6f, 0x75,
+	0x6e, 0x74, 0x12, 0x3c, 0x0a, 0x0a, 0x64, 0x65, 0x62, 0x75, 0x67, 0x5f, 0x69, 0x6e, 0x66, 0x6f,
+	0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63,
+	0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x62, 0x75, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x72, 0x6d,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x09, 0x64, 0x65, 0x62, 0x75, 0x67, 0x49, 0x6e, 0x66, 0x6f,
+	0x4a, 0x04, 0x08, 0x04, 0x10, 0x05, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x22, 0x46, 0x0a, 0x10,
+	0x44, 0x65, 0x62, 0x75, 0x67, 0x49, 0x6e, 0x66, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x12, 0x32, 0x0a, 0x05, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x1c, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68,
+	0x65, 0x63, 0x6b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x65, 0x52, 0x05, 0x63,
+	0x68, 0x65, 0x63, 0x6b, 0x22, 0xfc, 0x02, 0x0a, 0x0f, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x44, 0x65,
+	0x62, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x65, 0x12, 0x3b, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x64, 0x69, 0x73, 0x70,
 	0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68,
-	0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e,
-	0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70,
-	0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
-	0x73, 0x65, 0x22, 0x00, 0x12, 0x81, 0x01, 0x0a, 0x1a, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63,
+	0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x52, 0x07, 0x72, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x5f, 0x0a, 0x16, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63,
+	0x65, 0x5f, 0x72, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x29, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68,
+	0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x54, 0x72,
+	0x61, 0x63, 0x65, 0x2e, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65,
+	0x52, 0x14, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x52, 0x65, 0x6c, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x68, 0x61, 0x73, 0x5f, 0x70, 0x65,
+	0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d,
+	0x68, 0x61, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x28, 0x0a,
+	0x10, 0x69, 0x73, 0x5f, 0x63, 0x61, 0x63, 0x68, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c,
+	0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x69, 0x73, 0x43, 0x61, 0x63, 0x68, 0x65,
+	0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x3f, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x5f, 0x70,
+	0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e,
+	0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63,
+	0x6b, 0x44, 0x65, 0x62, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x65, 0x52, 0x0b, 0x73, 0x75, 0x62,
+	0x50, 0x72, 0x6f, 0x62, 0x6c, 0x65, 0x6d, 0x73, 0x22, 0x39, 0x0a, 0x0c, 0x52, 0x65, 0x6c, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e,
+	0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x52, 0x45, 0x4c, 0x41, 0x54, 0x49, 0x4f,
+	0x4e, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x50, 0x45, 0x52, 0x4d, 0x49, 0x53, 0x53, 0x49, 0x4f,
+	0x4e, 0x10, 0x02, 0x32, 0xa0, 0x04, 0x0a, 0x0f, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68,
+	0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x58, 0x0a, 0x0d, 0x44, 0x69, 0x73, 0x70, 0x61,
+	0x74, 0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x21, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61,
+	0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x43,
+	0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x64, 0x69,
+	0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74,
+	0x63, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
+	0x00, 0x12, 0x5b, 0x0a, 0x0e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, 0x70,
+	0x61, 0x6e, 0x64, 0x12, 0x22, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76,
+	0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78, 0x70, 0x61, 0x6e, 0x64,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74,
+	0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x45, 0x78,
+	0x70, 0x61, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x5b,
+	0x0a, 0x0e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70,
+	0x12, 0x22, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44,
+	0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e,
+	0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75,
+	0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x81, 0x01, 0x0a, 0x1a,
+	0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x63, 0x68, 0x61, 0x62, 0x6c,
+	0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x12, 0x2e, 0x2e, 0x64, 0x69, 0x73,
+	0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63,
+	0x68, 0x52, 0x65, 0x61, 0x63, 0x68, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
+	0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x64, 0x69, 0x73,
+	0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63,
 	0x68, 0x52, 0x65, 0x61, 0x63, 0x68, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72,
-	0x63, 0x65, 0x73, 0x12, 0x2e, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76,
-	0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x63, 0x68, 0x61,
-	0x62, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75,
-	0x65, 0x73, 0x74, 0x1a, 0x2f, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76,
-	0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x52, 0x65, 0x61, 0x63, 0x68, 0x61,
-	0x62, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70,
-	0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12, 0x75, 0x0a, 0x16, 0x44, 0x69, 0x73, 0x70,
-	0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63,
-	0x74, 0x73, 0x12, 0x2a, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31,
-	0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53,
-	0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b,
-	0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73,
-	0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65,
-	0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x42,
-	0xaa, 0x01, 0x0a, 0x0f, 0x63, 0x6f, 0x6d, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68,
-	0x2e, 0x76, 0x31, 0x42, 0x0d, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x50, 0x72, 0x6f,
-	0x74, 0x6f, 0x50, 0x01, 0x5a, 0x3b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d,
-	0x2f, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x65, 0x64, 0x2f, 0x73, 0x70, 0x69, 0x63, 0x65, 0x64, 0x62,
-	0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x64, 0x69, 0x73, 0x70, 0x61,
-	0x74, 0x63, 0x68, 0x2f, 0x76, 0x31, 0x3b, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x76,
-	0x31, 0xa2, 0x02, 0x03, 0x44, 0x58, 0x58, 0xaa, 0x02, 0x0b, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74,
-	0x63, 0x68, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0b, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68,
-	0x5c, 0x56, 0x31, 0xe2, 0x02, 0x17, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x5c, 0x56,
-	0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0c,
-	0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72,
-	0x6f, 0x74, 0x6f, 0x33,
+	0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x12,
+	0x75, 0x0a, 0x16, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b, 0x75,
+	0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x2a, 0x2e, 0x64, 0x69, 0x73, 0x70,
+	0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68,
+	0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68,
+	0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4c, 0x6f, 0x6f, 0x6b,
+	0x75, 0x70, 0x53, 0x75, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+	0x73, 0x65, 0x22, 0x00, 0x30, 0x01, 0x42, 0xaa, 0x01, 0x0a, 0x0f, 0x63, 0x6f, 0x6d, 0x2e, 0x64,
+	0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x76, 0x31, 0x42, 0x0d, 0x44, 0x69, 0x73, 0x70,
+	0x61, 0x74, 0x63, 0x68, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x3b, 0x67, 0x69, 0x74,
+	0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x7a, 0x65, 0x64, 0x2f,
+	0x73, 0x70, 0x69, 0x63, 0x65, 0x64, 0x62, 0x2f, 0x70, 0x6b, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x2f, 0x64, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2f, 0x76, 0x31, 0x3b, 0x64, 0x69,
+	0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x44, 0x58, 0x58, 0xaa, 0x02,
+	0x0b, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x0b, 0x44,
+	0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x17, 0x44, 0x69, 0x73,
+	0x70, 0x61, 0x74, 0x63, 0x68, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61,
+	0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0c, 0x44, 0x69, 0x73, 0x70, 0x61, 0x74, 0x63, 0x68, 0x3a,
+	0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
 }
 
 var (
@@ -1505,7 +1565,7 @@ func file_dispatch_v1_dispatch_proto_rawDescGZIP() []byte {
 }
 
 var file_dispatch_v1_dispatch_proto_enumTypes = make([]protoimpl.EnumInfo, 5)
-var file_dispatch_v1_dispatch_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
+var file_dispatch_v1_dispatch_proto_msgTypes = make([]protoimpl.MessageInfo, 16)
 var file_dispatch_v1_dispatch_proto_goTypes = []interface{}{
 	(DispatchCheckRequest_DebugSetting)(0),     // 0: dispatch.v1.DispatchCheckRequest.DebugSetting
 	(DispatchCheckResponse_Membership)(0),      // 1: dispatch.v1.DispatchCheckResponse.Membership
@@ -1522,64 +1582,66 @@ var file_dispatch_v1_dispatch_proto_goTypes = []interface{}{
 	(*ReachableResource)(nil),                  // 12: dispatch.v1.ReachableResource
 	(*DispatchReachableResourcesResponse)(nil), // 13: dispatch.v1.DispatchReachableResourcesResponse
 	(*DispatchLookupSubjectsRequest)(nil),      // 14: dispatch.v1.DispatchLookupSubjectsRequest
-	(*DispatchLookupSubjectsResponse)(nil),     // 15: dispatch.v1.DispatchLookupSubjectsResponse
-	(*ResolverMeta)(nil),                       // 16: dispatch.v1.ResolverMeta
-	(*ResponseMeta)(nil),                       // 17: dispatch.v1.ResponseMeta
-	(*DebugInformation)(nil),                   // 18: dispatch.v1.DebugInformation
-	(*CheckDebugTrace)(nil),                    // 19: dispatch.v1.CheckDebugTrace
-	(*v1.ObjectAndRelation)(nil),               // 20: core.v1.ObjectAndRelation
-	(*v1.RelationTupleTreeNode)(nil),           // 21: core.v1.RelationTupleTreeNode
-	(*v1.RelationReference)(nil),               // 22: core.v1.RelationReference
+	(*FoundSubject)(nil),                       // 15: dispatch.v1.FoundSubject
+	(*DispatchLookupSubjectsResponse)(nil),     // 16: dispatch.v1.DispatchLookupSubjectsResponse
+	(*ResolverMeta)(nil),                       // 17: dispatch.v1.ResolverMeta
+	(*ResponseMeta)(nil),                       // 18: dispatch.v1.ResponseMeta
+	(*DebugInformation)(nil),                   // 19: dispatch.v1.DebugInformation
+	(*CheckDebugTrace)(nil),                    // 20: dispatch.v1.CheckDebugTrace
+	(*v1.ObjectAndRelation)(nil),               // 21: core.v1.ObjectAndRelation
+	(*v1.RelationTupleTreeNode)(nil),           // 22: core.v1.RelationTupleTreeNode
+	(*v1.RelationReference)(nil),               // 23: core.v1.RelationReference
 }
 var file_dispatch_v1_dispatch_proto_depIdxs = []int32{
-	16, // 0: dispatch.v1.DispatchCheckRequest.metadata:type_name -> dispatch.v1.ResolverMeta
-	20, // 1: dispatch.v1.DispatchCheckRequest.resource_and_relation:type_name -> core.v1.ObjectAndRelation
-	20, // 2: dispatch.v1.DispatchCheckRequest.subject:type_name -> core.v1.ObjectAndRelation
+	17, // 0: dispatch.v1.DispatchCheckRequest.metadata:type_name -> dispatch.v1.ResolverMeta
+	21, // 1: dispatch.v1.DispatchCheckRequest.resource_and_relation:type_name -> core.v1.ObjectAndRelation
+	21, // 2: dispatch.v1.DispatchCheckRequest.subject:type_name -> core.v1.ObjectAndRelation
 	0,  // 3: dispatch.v1.DispatchCheckRequest.debug:type_name -> dispatch.v1.DispatchCheckRequest.DebugSetting
-	17, // 4: dispatch.v1.DispatchCheckResponse.metadata:type_name -> dispatch.v1.ResponseMeta
+	18, // 4: dispatch.v1.DispatchCheckResponse.metadata:type_name -> dispatch.v1.ResponseMeta
 	1,  // 5: dispatch.v1.DispatchCheckResponse.membership:type_name -> dispatch.v1.DispatchCheckResponse.Membership
-	16, // 6: dispatch.v1.DispatchExpandRequest.metadata:type_name -> dispatch.v1.ResolverMeta
-	20, // 7: dispatch.v1.DispatchExpandRequest.resource_and_relation:type_name -> core.v1.ObjectAndRelation
+	17, // 6: dispatch.v1.DispatchExpandRequest.metadata:type_name -> dispatch.v1.ResolverMeta
+	21, // 7: dispatch.v1.DispatchExpandRequest.resource_and_relation:type_name -> core.v1.ObjectAndRelation
 	2,  // 8: dispatch.v1.DispatchExpandRequest.expansion_mode:type_name -> dispatch.v1.DispatchExpandRequest.ExpansionMode
-	17, // 9: dispatch.v1.DispatchExpandResponse.metadata:type_name -> dispatch.v1.ResponseMeta
-	21, // 10: dispatch.v1.DispatchExpandResponse.tree_node:type_name -> core.v1.RelationTupleTreeNode
-	16, // 11: dispatch.v1.DispatchLookupRequest.metadata:type_name -> dispatch.v1.ResolverMeta
-	22, // 12: dispatch.v1.DispatchLookupRequest.object_relation:type_name -> core.v1.RelationReference
-	20, // 13: dispatch.v1.DispatchLookupRequest.subject:type_name -> core.v1.ObjectAndRelation
-	22, // 14: dispatch.v1.DispatchLookupRequest.direct_stack:type_name -> core.v1.RelationReference
-	22, // 15: dispatch.v1.DispatchLookupRequest.ttu_stack:type_name -> core.v1.RelationReference
-	17, // 16: dispatch.v1.DispatchLookupResponse.metadata:type_name -> dispatch.v1.ResponseMeta
-	20, // 17: dispatch.v1.DispatchLookupResponse.resolved_onrs:type_name -> core.v1.ObjectAndRelation
-	16, // 18: dispatch.v1.DispatchReachableResourcesRequest.metadata:type_name -> dispatch.v1.ResolverMeta
-	22, // 19: dispatch.v1.DispatchReachableResourcesRequest.resource_relation:type_name -> core.v1.RelationReference
-	22, // 20: dispatch.v1.DispatchReachableResourcesRequest.subject_relation:type_name -> core.v1.RelationReference
+	18, // 9: dispatch.v1.DispatchExpandResponse.metadata:type_name -> dispatch.v1.ResponseMeta
+	22, // 10: dispatch.v1.DispatchExpandResponse.tree_node:type_name -> core.v1.RelationTupleTreeNode
+	17, // 11: dispatch.v1.DispatchLookupRequest.metadata:type_name -> dispatch.v1.ResolverMeta
+	23, // 12: dispatch.v1.DispatchLookupRequest.object_relation:type_name -> core.v1.RelationReference
+	21, // 13: dispatch.v1.DispatchLookupRequest.subject:type_name -> core.v1.ObjectAndRelation
+	23, // 14: dispatch.v1.DispatchLookupRequest.direct_stack:type_name -> core.v1.RelationReference
+	23, // 15: dispatch.v1.DispatchLookupRequest.ttu_stack:type_name -> core.v1.RelationReference
+	18, // 16: dispatch.v1.DispatchLookupResponse.metadata:type_name -> dispatch.v1.ResponseMeta
+	21, // 17: dispatch.v1.DispatchLookupResponse.resolved_onrs:type_name -> core.v1.ObjectAndRelation
+	17, // 18: dispatch.v1.DispatchReachableResourcesRequest.metadata:type_name -> dispatch.v1.ResolverMeta
+	23, // 19: dispatch.v1.DispatchReachableResourcesRequest.resource_relation:type_name -> core.v1.RelationReference
+	23, // 20: dispatch.v1.DispatchReachableResourcesRequest.subject_relation:type_name -> core.v1.RelationReference
 	3,  // 21: dispatch.v1.ReachableResource.result_status:type_name -> dispatch.v1.ReachableResource.ResultStatus
 	12, // 22: dispatch.v1.DispatchReachableResourcesResponse.resource:type_name -> dispatch.v1.ReachableResource
-	17, // 23: dispatch.v1.DispatchReachableResourcesResponse.metadata:type_name -> dispatch.v1.ResponseMeta
-	16, // 24: dispatch.v1.DispatchLookupSubjectsRequest.metadata:type_name -> dispatch.v1.ResolverMeta
-	22, // 25: dispatch.v1.DispatchLookupSubjectsRequest.resource_relation:type_name -> core.v1.RelationReference
-	22, // 26: dispatch.v1.DispatchLookupSubjectsRequest.subject_relation:type_name -> core.v1.RelationReference
-	17, // 27: dispatch.v1.DispatchLookupSubjectsResponse.metadata:type_name -> dispatch.v1.ResponseMeta
-	18, // 28: dispatch.v1.ResponseMeta.debug_info:type_name -> dispatch.v1.DebugInformation
-	19, // 29: dispatch.v1.DebugInformation.check:type_name -> dispatch.v1.CheckDebugTrace
-	5,  // 30: dispatch.v1.CheckDebugTrace.request:type_name -> dispatch.v1.DispatchCheckRequest
-	4,  // 31: dispatch.v1.CheckDebugTrace.resource_relation_type:type_name -> dispatch.v1.CheckDebugTrace.RelationType
-	19, // 32: dispatch.v1.CheckDebugTrace.sub_problems:type_name -> dispatch.v1.CheckDebugTrace
-	5,  // 33: dispatch.v1.DispatchService.DispatchCheck:input_type -> dispatch.v1.DispatchCheckRequest
-	7,  // 34: dispatch.v1.DispatchService.DispatchExpand:input_type -> dispatch.v1.DispatchExpandRequest
-	9,  // 35: dispatch.v1.DispatchService.DispatchLookup:input_type -> dispatch.v1.DispatchLookupRequest
-	11, // 36: dispatch.v1.DispatchService.DispatchReachableResources:input_type -> dispatch.v1.DispatchReachableResourcesRequest
-	14, // 37: dispatch.v1.DispatchService.DispatchLookupSubjects:input_type -> dispatch.v1.DispatchLookupSubjectsRequest
-	6,  // 38: dispatch.v1.DispatchService.DispatchCheck:output_type -> dispatch.v1.DispatchCheckResponse
-	8,  // 39: dispatch.v1.DispatchService.DispatchExpand:output_type -> dispatch.v1.DispatchExpandResponse
-	10, // 40: dispatch.v1.DispatchService.DispatchLookup:output_type -> dispatch.v1.DispatchLookupResponse
-	13, // 41: dispatch.v1.DispatchService.DispatchReachableResources:output_type -> dispatch.v1.DispatchReachableResourcesResponse
-	15, // 42: dispatch.v1.DispatchService.DispatchLookupSubjects:output_type -> dispatch.v1.DispatchLookupSubjectsResponse
-	38, // [38:43] is the sub-list for method output_type
-	33, // [33:38] is the sub-list for method input_type
-	33, // [33:33] is the sub-list for extension type_name
-	33, // [33:33] is the sub-list for extension extendee
-	0,  // [0:33] is the sub-list for field type_name
+	18, // 23: dispatch.v1.DispatchReachableResourcesResponse.metadata:type_name -> dispatch.v1.ResponseMeta
+	17, // 24: dispatch.v1.DispatchLookupSubjectsRequest.metadata:type_name -> dispatch.v1.ResolverMeta
+	23, // 25: dispatch.v1.DispatchLookupSubjectsRequest.resource_relation:type_name -> core.v1.RelationReference
+	23, // 26: dispatch.v1.DispatchLookupSubjectsRequest.subject_relation:type_name -> core.v1.RelationReference
+	15, // 27: dispatch.v1.DispatchLookupSubjectsResponse.found_subjects:type_name -> dispatch.v1.FoundSubject
+	18, // 28: dispatch.v1.DispatchLookupSubjectsResponse.metadata:type_name -> dispatch.v1.ResponseMeta
+	19, // 29: dispatch.v1.ResponseMeta.debug_info:type_name -> dispatch.v1.DebugInformation
+	20, // 30: dispatch.v1.DebugInformation.check:type_name -> dispatch.v1.CheckDebugTrace
+	5,  // 31: dispatch.v1.CheckDebugTrace.request:type_name -> dispatch.v1.DispatchCheckRequest
+	4,  // 32: dispatch.v1.CheckDebugTrace.resource_relation_type:type_name -> dispatch.v1.CheckDebugTrace.RelationType
+	20, // 33: dispatch.v1.CheckDebugTrace.sub_problems:type_name -> dispatch.v1.CheckDebugTrace
+	5,  // 34: dispatch.v1.DispatchService.DispatchCheck:input_type -> dispatch.v1.DispatchCheckRequest
+	7,  // 35: dispatch.v1.DispatchService.DispatchExpand:input_type -> dispatch.v1.DispatchExpandRequest
+	9,  // 36: dispatch.v1.DispatchService.DispatchLookup:input_type -> dispatch.v1.DispatchLookupRequest
+	11, // 37: dispatch.v1.DispatchService.DispatchReachableResources:input_type -> dispatch.v1.DispatchReachableResourcesRequest
+	14, // 38: dispatch.v1.DispatchService.DispatchLookupSubjects:input_type -> dispatch.v1.DispatchLookupSubjectsRequest
+	6,  // 39: dispatch.v1.DispatchService.DispatchCheck:output_type -> dispatch.v1.DispatchCheckResponse
+	8,  // 40: dispatch.v1.DispatchService.DispatchExpand:output_type -> dispatch.v1.DispatchExpandResponse
+	10, // 41: dispatch.v1.DispatchService.DispatchLookup:output_type -> dispatch.v1.DispatchLookupResponse
+	13, // 42: dispatch.v1.DispatchService.DispatchReachableResources:output_type -> dispatch.v1.DispatchReachableResourcesResponse
+	16, // 43: dispatch.v1.DispatchService.DispatchLookupSubjects:output_type -> dispatch.v1.DispatchLookupSubjectsResponse
+	39, // [39:44] is the sub-list for method output_type
+	34, // [34:39] is the sub-list for method input_type
+	34, // [34:34] is the sub-list for extension type_name
+	34, // [34:34] is the sub-list for extension extendee
+	0,  // [0:34] is the sub-list for field type_name
 }
 
 func init() { file_dispatch_v1_dispatch_proto_init() }
@@ -1709,7 +1771,7 @@ func file_dispatch_v1_dispatch_proto_init() {
 			}
 		}
 		file_dispatch_v1_dispatch_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*DispatchLookupSubjectsResponse); i {
+			switch v := v.(*FoundSubject); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -1721,7 +1783,7 @@ func file_dispatch_v1_dispatch_proto_init() {
 			}
 		}
 		file_dispatch_v1_dispatch_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*ResolverMeta); i {
+			switch v := v.(*DispatchLookupSubjectsResponse); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -1733,7 +1795,7 @@ func file_dispatch_v1_dispatch_proto_init() {
 			}
 		}
 		file_dispatch_v1_dispatch_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*ResponseMeta); i {
+			switch v := v.(*ResolverMeta); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -1745,7 +1807,7 @@ func file_dispatch_v1_dispatch_proto_init() {
 			}
 		}
 		file_dispatch_v1_dispatch_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} {
-			switch v := v.(*DebugInformation); i {
+			switch v := v.(*ResponseMeta); i {
 			case 0:
 				return &v.state
 			case 1:
@@ -1757,6 +1819,18 @@ func file_dispatch_v1_dispatch_proto_init() {
 			}
 		}
 		file_dispatch_v1_dispatch_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*DebugInformation); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_dispatch_v1_dispatch_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} {
 			switch v := v.(*CheckDebugTrace); i {
 			case 0:
 				return &v.state
@@ -1775,7 +1849,7 @@ func file_dispatch_v1_dispatch_proto_init() {
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: file_dispatch_v1_dispatch_proto_rawDesc,
 			NumEnums:      5,
-			NumMessages:   15,
+			NumMessages:   16,
 			NumExtensions: 0,
 			NumServices:   1,
 		},
diff --git a/pkg/proto/dispatch/v1/dispatch.pb.validate.go b/pkg/proto/dispatch/v1/dispatch.pb.validate.go
index 89791f4a68..77b32dcf2f 100644
--- a/pkg/proto/dispatch/v1/dispatch.pb.validate.go
+++ b/pkg/proto/dispatch/v1/dispatch.pb.validate.go
@@ -1910,6 +1910,107 @@ var _ interface {
 	ErrorName() string
 } = DispatchLookupSubjectsRequestValidationError{}
 
+// Validate checks the field values on FoundSubject with the rules defined in
+// the proto definition for this message. If any rules are violated, the first
+// error encountered is returned, or nil if there are no violations.
+func (m *FoundSubject) Validate() error {
+	return m.validate(false)
+}
+
+// ValidateAll checks the field values on FoundSubject with the rules defined
+// in the proto definition for this message. If any rules are violated, the
+// result is a list of violation errors wrapped in FoundSubjectMultiError, or
+// nil if none found.
+func (m *FoundSubject) ValidateAll() error {
+	return m.validate(true)
+}
+
+func (m *FoundSubject) validate(all bool) error {
+	if m == nil {
+		return nil
+	}
+
+	var errors []error
+
+	// no validation rules for SubjectId
+
+	if len(errors) > 0 {
+		return FoundSubjectMultiError(errors)
+	}
+
+	return nil
+}
+
+// FoundSubjectMultiError is an error wrapping multiple validation errors
+// returned by FoundSubject.ValidateAll() if the designated constraints aren't met.
+type FoundSubjectMultiError []error
+
+// Error returns a concatenation of all the error messages it wraps.
+func (m FoundSubjectMultiError) Error() string {
+	var msgs []string
+	for _, err := range m {
+		msgs = append(msgs, err.Error())
+	}
+	return strings.Join(msgs, "; ")
+}
+
+// AllErrors returns a list of validation violation errors.
+func (m FoundSubjectMultiError) AllErrors() []error { return m }
+
+// FoundSubjectValidationError is the validation error returned by
+// FoundSubject.Validate if the designated constraints aren't met.
+type FoundSubjectValidationError struct {
+	field  string
+	reason string
+	cause  error
+	key    bool
+}
+
+// Field function returns field value.
+func (e FoundSubjectValidationError) Field() string { return e.field }
+
+// Reason function returns reason value.
+func (e FoundSubjectValidationError) Reason() string { return e.reason }
+
+// Cause function returns cause value.
+func (e FoundSubjectValidationError) Cause() error { return e.cause }
+
+// Key function returns key value.
+func (e FoundSubjectValidationError) Key() bool { return e.key }
+
+// ErrorName returns error name.
+func (e FoundSubjectValidationError) ErrorName() string { return "FoundSubjectValidationError" }
+
+// Error satisfies the builtin error interface
+func (e FoundSubjectValidationError) Error() string {
+	cause := ""
+	if e.cause != nil {
+		cause = fmt.Sprintf(" | caused by: %v", e.cause)
+	}
+
+	key := ""
+	if e.key {
+		key = "key for "
+	}
+
+	return fmt.Sprintf(
+		"invalid %sFoundSubject.%s: %s%s",
+		key,
+		e.field,
+		e.reason,
+		cause)
+}
+
+var _ error = FoundSubjectValidationError{}
+
+var _ interface {
+	Field() string
+	Reason() string
+	Key() bool
+	Cause() error
+	ErrorName() string
+} = FoundSubjectValidationError{}
+
 // Validate checks the field values on DispatchLookupSubjectsResponse with the
 // rules defined in the proto definition for this message. If any rules are
 // violated, the first error encountered is returned, or nil if there are no violations.
@@ -1932,6 +2033,40 @@ func (m *DispatchLookupSubjectsResponse) validate(all bool) error {
 
 	var errors []error
 
+	for idx, item := range m.GetFoundSubjects() {
+		_, _ = idx, item
+
+		if all {
+			switch v := interface{}(item).(type) {
+			case interface{ ValidateAll() error }:
+				if err := v.ValidateAll(); err != nil {
+					errors = append(errors, DispatchLookupSubjectsResponseValidationError{
+						field:  fmt.Sprintf("FoundSubjects[%v]", idx),
+						reason: "embedded message failed validation",
+						cause:  err,
+					})
+				}
+			case interface{ Validate() error }:
+				if err := v.Validate(); err != nil {
+					errors = append(errors, DispatchLookupSubjectsResponseValidationError{
+						field:  fmt.Sprintf("FoundSubjects[%v]", idx),
+						reason: "embedded message failed validation",
+						cause:  err,
+					})
+				}
+			}
+		} else if v, ok := interface{}(item).(interface{ Validate() error }); ok {
+			if err := v.Validate(); err != nil {
+				return DispatchLookupSubjectsResponseValidationError{
+					field:  fmt.Sprintf("FoundSubjects[%v]", idx),
+					reason: "embedded message failed validation",
+					cause:  err,
+				}
+			}
+		}
+
+	}
+
 	if all {
 		switch v := interface{}(m.GetMetadata()).(type) {
 		case interface{ ValidateAll() error }:
diff --git a/pkg/proto/impl/v1/impl.pb.go b/pkg/proto/impl/v1/impl.pb.go
index 125e9d503c..297bee8ac3 100644
--- a/pkg/proto/impl/v1/impl.pb.go
+++ b/pkg/proto/impl/v1/impl.pb.go
@@ -76,6 +76,7 @@ type DecodedZookie struct {
 
 	Version uint32 `protobuf:"varint,1,opt,name=version,proto3" json:"version,omitempty"`
 	// Types that are assignable to VersionOneof:
+	//
 	//	*DecodedZookie_V1
 	//	*DecodedZookie_V2
 	VersionOneof isDecodedZookie_VersionOneof `protobuf_oneof:"version_oneof"`
@@ -163,6 +164,7 @@ type DecodedZedToken struct {
 	unknownFields protoimpl.UnknownFields
 
 	// Types that are assignable to VersionOneof:
+	//
 	//	*DecodedZedToken_DeprecatedV1Zookie
 	//	*DecodedZedToken_V1
 	VersionOneof isDecodedZedToken_VersionOneof `protobuf_oneof:"version_oneof"`
diff --git a/pkg/tuple/onrbytypeset.go b/pkg/tuple/onrbytypeset.go
index e9493c5a70..7b2049eafd 100644
--- a/pkg/tuple/onrbytypeset.go
+++ b/pkg/tuple/onrbytypeset.go
@@ -54,6 +54,9 @@ func (s *ONRByTypeSet) Map(mapper func(rr *core.RelationReference) (*core.Relati
 		if err != nil {
 			return nil, err
 		}
+		if updatedType == nil {
+			continue
+		}
 		updatedTypeKey := fmt.Sprintf("%s#%s", updatedType.Namespace, updatedType.Relation)
 		mapped.byType[updatedTypeKey] = objectIds
 	}
diff --git a/proto/internal/dispatch/v1/dispatch.proto b/proto/internal/dispatch/v1/dispatch.proto
index b9df367033..13dd4e6aff 100644
--- a/proto/internal/dispatch/v1/dispatch.proto
+++ b/proto/internal/dispatch/v1/dispatch.proto
@@ -123,8 +123,13 @@ message DispatchLookupSubjectsRequest {
       [ (validate.rules).message.required = true ];
 }
 
+message FoundSubject {
+  string subject_id = 1;
+  repeated string excluded_subject_ids = 2;
+}
+
 message DispatchLookupSubjectsResponse {
-  repeated string found_subject_ids = 1;
+  repeated FoundSubject found_subjects = 1;
   ResponseMeta metadata = 2;
 }
 
diff --git a/tools/analyzers/go.mod b/tools/analyzers/go.mod
index 02d075de80..d38a530a35 100644
--- a/tools/analyzers/go.mod
+++ b/tools/analyzers/go.mod
@@ -1,14 +1,13 @@
 module github.com/authzed/spicedb/tools/analyzers
 
-go 1.18
+go 1.19
 
 require (
 	github.com/jzelinskie/stringz v0.0.1
-	golang.org/x/tools v0.1.10
+	golang.org/x/tools v0.1.12
 )
 
 require (
-	golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect
-	golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 // indirect
-	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
+	golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
+	golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect
 )
diff --git a/tools/analyzers/go.sum b/tools/analyzers/go.sum
index 14e26dad4f..3520b29844 100644
--- a/tools/analyzers/go.sum
+++ b/tools/analyzers/go.sum
@@ -1,10 +1,8 @@
 github.com/jzelinskie/stringz v0.0.1 h1:IahR+y8ct2nyj7B6i8UtFsGFj4ex1SX27iKFYsAheLk=
 github.com/jzelinskie/stringz v0.0.1/go.mod h1:hHYbgxJuNLRw91CmpuFsYEOyQqpDVFg8pvEh23vy4P0=
-golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdxfwMYh3AVStj88WQTlNDi2a+o=
-golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
-golang.org/x/sys v0.0.0-20211019181941-9d821ace8654 h1:id054HUawV2/6IGm2IV8KZQjqtwAOo2CYlOToYqa0d0=
-golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/tools v0.1.10 h1:QjFRCZxdOhBJ/UNgnBZLbNV13DlbnK0quyivTnXJM20=
-golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=