CVE-2024-31452 (published 2024-04-16T22:15Z)

OpenFGA is a high-performance and flexible authorization/permission engine. Some end users of OpenFGA v1.5.0 or later are vulnerable to authorization bypass when calling Check or ListObjects APIs. You are very likely affected if your model involves exclusion (e.g. `a but not b`) or intersection (e.g. `a and b`). This vulnerability is fixed in v1.5.3.


Weakness information:
cwe_id: NVD-CWE-noinfo
cwe_name: Insufficient Information
description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
extended_description: Insufficient Information

Fix information:
repo_url: https://github.com/openfga/openfga
commit_details: repo_url: https://github.com/openfga/openfga
author: Jonathan Whitaker
author_date: 2024-04-16 15:27:19-06:00
msg: Merge pull request from GHSA-8cph-m685-6v6r

* fix: error handling leading to false positives

* test: add a failing test due to Check and ListObjects differences

* test: change test expectation for ListObjects assertion

* fix: union CheckFuncReducer and associated tests

* fix: intersection CheckFuncReducer

* fix: exclusion CheckFuncReducer

* fix: rewire the cycle detection mechanics to avoid error propagation

* test: add more edge case tests for cyclical evaluations

* test: rename new test that was poorly named

* chore: revert minor change that was unintended

* chore: tidy up changes and drop commented out code

* chore: minor touchups based on some feedback

* test: add test for CloneResolveCheckResponse

* test: add more tests for error around intersection

* test: more test assertions around cloned cached response

* test: add unit test to make sure we cache CycleDetected

* test: add more assertions around CycleDetected field

* chore: rename test names

* chore: fix datastore query count under reporting in intersection reducer
repository: repo_url: https://github.com/openfga/openfga
repo_name: openfga/openfga
description: A high performance and flexible authorization/permission engine built for developers and inspired by Google Zanzibar
repo_language: Go
owner: openfga
file_changes: filename: consolidated_1_1_tests.yaml
change_type: ModificationType.MODIFY
diff: @@ -5295,3 +5295,364 @@ tests:
               relation: viewer
             expectation:
               - document:1
+  - name: cycle_or_cycle_return_false
+    stages:
+      - model: |
+          model
+            schema 1.1
+          type user
+
+          type document
+            relations
+              define editor: [user, document#viewer]
+              define viewer: [document#editor] or editor
+        tuples:
+          - user: document:1#viewer
+            relation: editor
+            object: document:1
+          - user: document:1#editor
+            relation: viewer
+            object: document:1
+        checkAssertions:
+          - tuple:
+              object: document:1
+              relation: viewer
+              user: user:jon
+            expectation: false
+        listObjectsAssertions:
+          - request:
+              user: user:jon
+              type: document
+              relation: viewer
+            expectation:
+  - name: immediate_cycle_through_computed_userset
+    stages:
+      - model: |
+          model
+            schema 1.1
+          type user
+
+          type document
+            relations
+              define editor: [user, document#viewer]
+              define viewer: editor
+        tuples:
+          - user: document:1#viewer
+            relation: editor
+            object: document:1
+        checkAssertions:
+          - tuple:
+              object: document:1
+              relation: viewer
+              user: user:jon
+            expectation: false
+        listObjectsAssertions:
+          - request:
+              user: user:jon
+              type: document
+              relation: viewer
+            expectation:
+  - name: immediate_cycle_through_computed_userset
+    stages:
+      - model: |
+          model
+            schema 1.1
+          type user
+
+          type document
+            relations
+              define editor: [user, document#viewer]
+              define viewer: editor
+        tuples:
+          - user: document:1#viewer
+            relation: editor
+            object: document:1
+        checkAssertions:
+          - tuple:
+              object: document:1
+              relation: viewer
+              user: user:jon
+            expectation: false
+        listObjectsAssertions:
+          - request:
+              user: user:jon
+              type: document
+              relation: viewer
+            expectation:
+  - name: true_butnot_cycle_return_false
+    stages:
+      - model: |
+          model
+            schema 1.1
+          type user
+
+          type document
+            relations
+              define restricted: [user, document#viewer]
+              define viewer: [user] but not restricted
+        tuples:
+          - user: user:jon
+            relation: viewer
+            object: document:1
+          - user: document:1#viewer
+            relation: restricted
+            object: document:1
+        checkAssertions:
+          - tuple:
+              object: document:1
+              relation: viewer
+              user: user:jon
+            expectation: false
+        listObjectsAssertions:
+          - request:
+              user: user:jon
+              type: document
+              relation: viewer
+            expectation:
+  - name: cycle_and_cycle_return_false
+    stages:
+      - model: |
+          model
+            schema 1.1
+
+          type user
+
+          type document
+            relations
+              define editor: [user, document#viewer]
+              define viewer: [user, document#editor] and editor
+        tuples:
+          - user: document:1#editor
+            relation: viewer
+            object: document:1
+          - user: document:1#viewer
+            relation: editor
+            object: document:1
+        checkAssertions:
+          - tuple:
+              object: document:1
+              relation: viewer
+              user: user:jon
+            expectation: false
+        listObjectsAssertions:
+          - request:
+              user: user:jon
+              type: document
+              relation: viewer
+            expectation:
+  - name: cycle_and_true_return_false
+    stages:
+      - model: |
+          model
+            schema 1.1
+
+          type user
+
+          type document
+            relations
+              define allowed: [user]
+              define viewer: [user, document#viewer] and allowed
+        tuples:
+          - user: user:jon
+            relation: allowed
+            object: document:1
+          - user: document:1#viewer
+            relation: viewer
+            object: document:1
+        checkAssertions:
+          - tuple:
+              object: document:1
+              relation: viewer
+              user: user:jon
+            expectation: false
+        listObjectsAssertions:
+          - request:
+              user: user:jon
+              type: document
+              relation: viewer
+            expectation:
+  - name: immediate_cycle_return_false
+    stages:
+      - model: |
+          model
+            schema 1.1
+
+          type user
+
+          type document
+            relations
+              define viewer: [user, document#viewer]
+        tuples:
+          - user: document:1#viewer
+            relation: viewer
+            object: document:1
+        checkAssertions:
+          - tuple:
+              object: document:1
+              relation: viewer
+              user: user:jon
+            expectation: false
+        listObjectsAssertions:
+          - request:
+              user: user:jon
+              type: document
+              relation: viewer
+            expectation:
+  - name: cycle_butnot_false_return_false
+    stages:
+      - model: |
+          model
+            schema 1.1
+
+          type user
+
+          type document
+            relations
+              define restricted: [user]
+              define viewer: [user, document#viewer] but not restricted
+        tuples:
+          - user: document:1#viewer
+            relation: viewer
+            object: document:1
+        checkAssertions:
+          - tuple:
+              object: document:1
+              relation: viewer
+              user: user:jon
+            expectation: false
+        listObjectsAssertions:
+          - request:
+              user: user:jon
+              type: document
+              relation: viewer
+            expectation:
+  - name: false_butnot_cycle_return_false
+    stages:
+      - model: |
+          model
+            schema 1.1
+
+          type user
+
+          type document
+            relations
+              define restricted: [user, document#viewer]
+              define viewer: [user] but not restricted
+        tuples:
+          - user: document:1#viewer
+            relation: restricted
+            object: document:1
+        checkAssertions:
+          - tuple:
+              object: document:1
+              relation: viewer
+              user: user:jon
+            expectation: false
+        listObjectsAssertions:
+          - request:
+              user: user:jon
+              type: document
+              relation: viewer
+            expectation:
+  - name: err_and_err_return_err
+    stages:
+      - model: |
+          model
+            schema 1.1
+          type user
+          type resource
+            relations
+              define a1: a2
+              define a2: a3
+              define a3: a4
+              define a4: a5
+              define a5: a6
+              define a6: a7
+              define a7: a8
+              define a8: a9
+              define a9: a10
+              define a10: a11
+              define a11: a12
+              define a12: a13
+              define a13: a14
+              define a14: a15
+              define a15: a16
+              define a16: a17
+              define a17: a18
+              define a18: a19
+              define a19: a20
+              define a20: a21
+              define a21: a22
+              define a22: a23
+              define a23: a24
+              define a24: a25
+              define a25: a26
+              define a26: [user]
+              define can_view: a1 and a1
+        tuples:
+          - object: resource:abc
+            relation: a26
+            user: user:maria
+        checkAssertions:
+          - tuple:
+              object: resource:abc
+              relation: can_view
+              user: user:maria
+            errorCode: 2002
+        listObjectsAssertions:
+          - request:
+              type: resource
+              relation: can_view
+              user: user:maria
+            errorCode: 2002
+  - name: err_and_true_return_err
+    stages:
+      - model: |
+          model
+            schema 1.1
+          type user
+          type resource
+            relations
+              define a1: a2
+              define a2: a3
+              define a3: a4
+              define a4: a5
+              define a5: a6
+              define a6: a7
+              define a7: a8
+              define a8: a9
+              define a9: a10
+              define a10: a11
+              define a11: a12
+              define a12: a13
+              define a13: a14
+              define a14: a15
+              define a15: a16
+              define a16: a17
+              define a17: a18
+              define a18: a19
+              define a19: a20
+              define a20: a21
+              define a21: a22
+              define a22: a23
+              define a23: a24
+              define a24: a25
+              define a25: a26
+              define a26: [user]
+              define can_view: a1 and a26
+        tuples:
+          - object: resource:abc
+            relation: a26
+            user: user:maria
+        checkAssertions:
+          - tuple:
+              object: resource:abc
+              relation: can_view
+              user: user:maria
+            errorCode: 2002
+        listObjectsAssertions:
+          - request:
+              type: resource
+              relation: can_view
+              user: user:maria
+            errorCode: 2002
\ No newline at end of file

programming_language: CoffeeScript
filename: cached_resolver.go
change_type: ModificationType.MODIFY
diff: @@ -39,33 +39,11 @@ var (
 	})
 )
 
-// CachedResolveCheckResponse is very similar to ResolveCheckResponse except we
-// do not store the ResolutionData. This is due to the fact that the resolution metadata
-// will be incorrect as data is served from cache instead of actual database read.
-type CachedResolveCheckResponse struct {
-	Allowed bool
-}
-
-func (c *CachedResolveCheckResponse) convertToResolveCheckResponse() *ResolveCheckResponse {
-	return &ResolveCheckResponse{
-		Allowed: c.Allowed,
-		ResolutionMetadata: &ResolveCheckResponseMetadata{
-			DatastoreQueryCount: 0,
-		},
-	}
-}
-
-func newCachedResolveCheckResponse(r *ResolveCheckResponse) *CachedResolveCheckResponse {
-	return &CachedResolveCheckResponse{
-		Allowed: r.Allowed,
-	}
-}
-
 // CachedCheckResolver attempts to resolve check sub-problems via prior computations before
 // delegating the request to some underlying CheckResolver.
 type CachedCheckResolver struct {
 	delegate     CheckResolver
-	cache        *ccache.Cache[*CachedResolveCheckResponse]
+	cache        *ccache.Cache[*ResolveCheckResponse]
 	maxCacheSize int64
 	cacheTTL     time.Duration
 	logger       logger.Logger
@@ -98,7 +76,7 @@ func WithCacheTTL(ttl time.Duration) CachedCheckResolverOpt {
 // WithExistingCache sets the cache to the specified cache.
 // Note that the original cache will not be stopped as it may still be used by others. It is up to the caller
 // to check whether the original cache should be stopped.
-func WithExistingCache(cache *ccache.Cache[*CachedResolveCheckResponse]) CachedCheckResolverOpt {
+func WithExistingCache(cache *ccache.Cache[*ResolveCheckResponse]) CachedCheckResolverOpt {
 	return func(ccr *CachedCheckResolver) {
 		ccr.cache = cache
 	}
@@ -131,7 +109,7 @@ func NewCachedCheckResolver(opts ...CachedCheckResolverOpt) *CachedCheckResolver
 	if checker.cache == nil {
 		checker.allocatedCache = true
 		checker.cache = ccache.New(
-			ccache.Configure[*CachedResolveCheckResponse]().MaxSize(checker.maxCacheSize),
+			ccache.Configure[*ResolveCheckResponse]().MaxSize(checker.maxCacheSize),
 		)
 	}
 
@@ -179,7 +157,8 @@ func (c *CachedCheckResolver) ResolveCheck(
 	if cachedResp != nil && !cachedResp.Expired() {
 		checkCacheHitCounter.Inc()
 		span.SetAttributes(attribute.Bool("is_cached", true))
-		return cachedResp.Value().convertToResolveCheckResponse(), nil
+
+		return cachedResp.Value(), nil
 	}
 	span.SetAttributes(attribute.Bool("is_cached", false))
 
@@ -189,7 +168,13 @@ func (c *CachedCheckResolver) ResolveCheck(
 		return nil, err
 	}
 
-	c.cache.Set(cacheKey, newCachedResolveCheckResponse(resp), c.cacheTTL)
+	// the cached subproblem's resolution metadata doesn't necessarily reflect
+	// the actual number of database reads for the inflight request, so set it
+	// to 0 so it doesn't bias the resolution metadata negatively
+	clonedResp := CloneResolveCheckResponse(resp)
+	clonedResp.ResolutionMetadata.DatastoreQueryCount = 0
+
+	c.cache.Set(cacheKey, clonedResp, c.cacheTTL)
 	return resp, nil
 }
 

programming_language: Go
filename: cached_resolver_test.go
change_type: ModificationType.MODIFY
diff: @@ -453,6 +453,39 @@ func TestResolveCheckExpired(t *testing.T) {
 	require.NoError(t, err)
 }
 
+func TestCachedCheckResolver_CycleDetected(t *testing.T) {
+	cachedCheckResolver := NewCachedCheckResolver()
+	defer cachedCheckResolver.Close()
+
+	mockCtrl := gomock.NewController(t)
+	t.Cleanup(mockCtrl.Finish)
+
+	mockCheckResolver := NewMockCheckResolver(mockCtrl)
+	cachedCheckResolver.SetDelegate(mockCheckResolver)
+
+	mockCheckResolver.EXPECT().
+		ResolveCheck(gomock.Any(), gomock.Any()).
+		Return(&ResolveCheckResponse{
+			Allowed: false,
+			ResolutionMetadata: &ResolveCheckResponseMetadata{
+				DatastoreQueryCount: 1,
+				CycleDetected:       true,
+			},
+		}, nil)
+
+	resp, err := cachedCheckResolver.ResolveCheck(context.Background(), &ResolveCheckRequest{})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	require.Equal(t, uint32(1), resp.GetResolutionMetadata().DatastoreQueryCount)
+	require.True(t, resp.GetResolutionMetadata().CycleDetected)
+
+	resp, err = cachedCheckResolver.ResolveCheck(context.Background(), &ResolveCheckRequest{})
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	require.Equal(t, uint32(0), resp.GetResolutionMetadata().DatastoreQueryCount)
+	require.True(t, resp.GetResolutionMetadata().CycleDetected)
+}
+
 func TestCachedCheckDatastoreQueryCount(t *testing.T) {
 	t.Parallel()
 
@@ -500,7 +533,7 @@ type document
 	ctx = storage.ContextWithRelationshipTupleReader(ctx, ds)
 
 	checkCache := ccache.New(
-		ccache.Configure[*CachedResolveCheckResponse]().MaxSize(100),
+		ccache.Configure[*ResolveCheckResponse]().MaxSize(100),
 	)
 	defer checkCache.Stop()
 

programming_language: Go
filename: check.go
change_type: ModificationType.MODIFY
diff: @@ -26,10 +26,6 @@ import (
 
 var tracer = otel.Tracer("internal/graph/check")
 
-var (
-	ErrCycleDetected = errors.New("a cycle has been detected")
-)
-
 type ResolveCheckRequest struct {
 	StoreID              string
 	AuthorizationModelID string
@@ -56,11 +52,40 @@ func clone(r *ResolveCheckRequest) *ResolveCheckRequest {
 	}
 }
 
+// CloneResolveCheckResponse clones the provided ResolveCheckResponse.
+//
+// If 'r' defines a nil ResolutionMetadata then this function returns
+// an empty value struct for the resolution metadata instead of nil.
+func CloneResolveCheckResponse(r *ResolveCheckResponse) *ResolveCheckResponse {
+	resolutionMetadata := &ResolveCheckResponseMetadata{
+		DatastoreQueryCount: 0,
+		CycleDetected:       false,
+	}
+
+	if r.GetResolutionMetadata() != nil {
+		resolutionMetadata.DatastoreQueryCount = r.GetResolutionMetadata().DatastoreQueryCount
+		resolutionMetadata.CycleDetected = r.GetResolutionMetadata().CycleDetected
+	}
+
+	return &ResolveCheckResponse{
+		Allowed:            r.GetAllowed(),
+		ResolutionMetadata: resolutionMetadata,
+	}
+}
+
 type ResolveCheckResponse struct {
 	Allowed            bool
 	ResolutionMetadata *ResolveCheckResponseMetadata
 }
 
+func (r *ResolveCheckResponse) GetCycleDetected() bool {
+	if r != nil {
+		return r.GetResolutionMetadata().CycleDetected
+	}
+
+	return false
+}
+
 func (r *ResolveCheckResponse) GetAllowed() bool {
 	if r != nil {
 		return r.Allowed
@@ -286,16 +311,19 @@ func union(ctx context.Context, concurrencyLimit uint32, handlers ...CheckHandle
 
 	var dbReads uint32
 	var err error
+	var cycleDetected bool
 	for i := 0; i < len(handlers); i++ {
 		select {
 		case result := <-resultChan:
 			if result.err != nil {
-				if errors.Is(result.err, ErrCycleDetected) {
-					continue
-				}
 				err = result.err
 				continue
 			}
+
+			if result.resp.GetCycleDetected() {
+				cycleDetected = true
+			}
+
 			dbReads += result.resp.GetResolutionMetadata().DatastoreQueryCount
 
 			if result.resp.GetAllowed() {
@@ -307,12 +335,17 @@ func union(ctx context.Context, concurrencyLimit uint32, handlers ...CheckHandle
 		}
 	}
 
+	if err != nil {
+		return nil, err
+	}
+
 	return &ResolveCheckResponse{
 		Allowed: false,
 		ResolutionMetadata: &ResolveCheckResponseMetadata{
 			DatastoreQueryCount: dbReads,
+			CycleDetected:       cycleDetected,
 		},
-	}, err
+	}, nil
 }
 
 // intersection implements a CheckFuncReducer that requires all of the provided CheckHandlerFunc to resolve
@@ -345,23 +378,13 @@ func intersection(ctx context.Context, concurrencyLimit uint32, handlers ...Chec
 		case result := <-resultChan:
 			if result.err != nil {
 				span.RecordError(result.err)
-
-				if errors.Is(result.err, ErrCycleDetected) {
-					return &ResolveCheckResponse{
-						Allowed: false,
-						ResolutionMetadata: &ResolveCheckResponseMetadata{
-							DatastoreQueryCount: dbReads,
-						},
-					}, nil
-				}
-
 				err = errors.Join(err, result.err)
 				continue
 			}
 
 			dbReads += result.resp.GetResolutionMetadata().DatastoreQueryCount
 
-			if !result.resp.GetAllowed() {
+			if result.resp.GetCycleDetected() || !result.resp.GetAllowed() {
 				result.resp.GetResolutionMetadata().DatastoreQueryCount = dbReads
 				return result.resp, nil
 			}
@@ -370,12 +393,17 @@ func intersection(ctx context.Context, concurrencyLimit uint32, handlers ...Chec
 		}
 	}
 
+	// all operands are either truthy or we've seen at least one error
+	if err != nil {
+		return nil, err
+	}
+
 	return &ResolveCheckResponse{
 		Allowed: true,
 		ResolutionMetadata: &ResolveCheckResponseMetadata{
 			DatastoreQueryCount: dbReads,
 		},
-	}, err
+	}, nil
 }
 
 // exclusion implements a CheckFuncReducer that requires a 'base' CheckHandlerFunc to resolve to an allowed
@@ -440,22 +468,22 @@ func exclusion(ctx context.Context, concurrencyLimit uint32, handlers ...CheckHa
 		case baseResult := <-baseChan:
 			if baseResult.err != nil {
 				span.RecordError(baseResult.err)
-
-				if errors.Is(baseResult.err, ErrCycleDetected) {
-					return &ResolveCheckResponse{
-						Allowed: false,
-						ResolutionMetadata: &ResolveCheckResponseMetadata{
-							DatastoreQueryCount: dbReads,
-						},
-					}, nil
-				}
-
 				baseErr = baseResult.err
 				continue
 			}
 
 			dbReads += baseResult.resp.GetResolutionMetadata().DatastoreQueryCount
 
+			if baseResult.resp.GetCycleDetected() {
+				return &ResolveCheckResponse{
+					Allowed: false,
+					ResolutionMetadata: &ResolveCheckResponseMetadata{
+						DatastoreQueryCount: dbReads,
+						CycleDetected:       true,
+					},
+				}, nil
+			}
+
 			if !baseResult.resp.GetAllowed() {
 				response.GetResolutionMetadata().DatastoreQueryCount = dbReads
 				return response, nil
@@ -464,16 +492,22 @@ func exclusion(ctx context.Context, concurrencyLimit uint32, handlers ...CheckHa
 		case subResult := <-subChan:
 			if subResult.err != nil {
 				span.RecordError(subResult.err)
-
-				if !errors.Is(subResult.err, ErrCycleDetected) {
-					subErr = subResult.err
-				}
-
+				subErr = subResult.err
 				continue
 			}
 
 			dbReads += subResult.resp.GetResolutionMetadata().DatastoreQueryCount
 
+			if subResult.resp.GetCycleDetected() {
+				return &ResolveCheckResponse{
+					Allowed: false,
+					ResolutionMetadata: &ResolveCheckResponseMetadata{
+						DatastoreQueryCount: dbReads,
+						CycleDetected:       true,
+					},
+				}, nil
+			}
+
 			if subResult.resp.GetAllowed() {
 				response.GetResolutionMetadata().DatastoreQueryCount = dbReads
 				return response, nil
@@ -483,13 +517,13 @@ func exclusion(ctx context.Context, concurrencyLimit uint32, handlers ...CheckHa
 		}
 	}
 
-	if baseErr != nil && subErr != nil {
-		return &ResolveCheckResponse{
-			Allowed: false,
-			ResolutionMetadata: &ResolveCheckResponseMetadata{
-				DatastoreQueryCount: 0,
-			},
-		}, errors.Join(baseErr, subErr)
+	// base is either (true) or error, sub is either (false) or error:
+	// true, false - true
+	// true, error - error
+	// error, false - error
+	// error, error - error
+	if baseErr != nil || subErr != nil {
+		return nil, errors.Join(baseErr, subErr)
 	}
 
 	return &ResolveCheckResponse{
@@ -515,7 +549,7 @@ func (c *LocalChecker) dispatch(_ context.Context, parentReq *ResolveCheckReques
 
 		resp, err := c.delegate.ResolveCheck(ctx, childRequest)
 		if err != nil {
-			return resp, err
+			return nil, err
 		}
 		return resp, nil
 	}
@@ -557,7 +591,7 @@ func (c *LocalChecker) ResolveCheck(
 		return nil, fmt.Errorf("relation '%s' undefined for object type '%s'", relation, objectType)
 	}
 
-	resp, err := union(ctx, c.concurrencyLimit, c.checkRewrite(ctx, req, rel.GetRewrite()))
+	resp, err := c.checkRewrite(ctx, req, rel.GetRewrite())(ctx)
 	if err != nil {
 		telemetry.TraceError(span, err)
 		return nil, err
@@ -614,7 +648,7 @@ func (c *LocalChecker) checkDirect(parentctx context.Context, req *ResolveCheckR
 					return response, nil
 				}
 
-				return response, err
+				return nil, err
 			}
 
 			// filter out invalid tuples yielded by the database query
@@ -669,7 +703,7 @@ func (c *LocalChecker) checkDirect(parentctx context.Context, req *ResolveCheckR
 				AllowedUserTypeRestrictions: directlyRelatedUsersetTypes,
 			})
 			if err != nil {
-				return response, err
+				return nil, err
 			}
 			defer iter.Stop()
 
@@ -689,7 +723,7 @@ func (c *LocalChecker) checkDirect(parentctx context.Context, req *ResolveCheckR
 						break
 					}
 
-					return response, err
+					return nil, err
 				}
 
 				condEvalResult, err := eval.EvaluateTupleCondition(ctx, t, typesys, req.GetContext())
@@ -833,19 +867,13 @@ func (c *LocalChecker) checkTTU(parentctx context.Context, req *ResolveCheckRequ
 		span.SetAttributes(attribute.String("tupleset_relation", fmt.Sprintf("%s#%s", tuple.GetType(object), tuplesetRelation)))
 		span.SetAttributes(attribute.String("computed_relation", computedRelation))
 
-		response := &ResolveCheckResponse{
-			Allowed: false,
-			ResolutionMetadata: &ResolveCheckResponseMetadata{
-				DatastoreQueryCount: req.GetRequestMetadata().DatastoreQueryCount + 1,
-			},
-		}
 		iter, err := ds.Read(
 			ctx,
 			req.GetStoreID(),
 			tuple.NewTupleKey(object, tuplesetRelation, ""),
 		)
 		if err != nil {
-			return response, err
+			return nil, err
 		}
 		defer iter.Stop()
 
@@ -865,7 +893,7 @@ func (c *LocalChecker) checkTTU(parentctx context.Context, req *ResolveCheckRequ
 					break
 				}
 
-				return response, err
+				return nil, err
 			}
 
 			condEvalResult, err := eval.EvaluateTupleCondition(ctx, t, typesys, req.GetContext())

programming_language: Go
filename: check_test.go
change_type: ModificationType.MODIFY
diff: @@ -2,6 +2,7 @@ package graph
 
 import (
 	"context"
+	"errors"
 	"fmt"
 	"sync"
 	"testing"
@@ -46,7 +47,19 @@ var (
 	}
 
 	cyclicErrorHandler = func(context.Context) (*ResolveCheckResponse, error) {
-		return nil, ErrCycleDetected
+		return &ResolveCheckResponse{
+			Allowed: false,
+			ResolutionMetadata: &ResolveCheckResponseMetadata{
+				DatastoreQueryCount: 1,
+				CycleDetected:       true,
+			},
+		}, nil
+	}
+
+	simulatedDBErrorMessage = "simulated db error"
+
+	generalErrorHandler = func(context.Context) (*ResolveCheckResponse, error) {
+		return nil, errors.New(simulatedDBErrorMessage)
 	}
 )
 
@@ -77,67 +90,178 @@ func TestExclusionCheckFuncReducer(t *testing.T) {
 		})
 	})
 
-	t.Run("base_handler_is_falsy_return_allowed:false", func(t *testing.T) {
+	t.Run("true_butnot_true_return_false", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, trueHandler, trueHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+1))
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("true_butnot_false_return_true", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, trueHandler, falseHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.True(t, resp.GetAllowed())
+		require.Equal(t, uint32(1+1), resp.GetResolutionMetadata().DatastoreQueryCount)
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("false_butnot_true_return_false", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, falseHandler, trueHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+1))
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("false_butnot_false_return_false", func(t *testing.T) {
 		resp, err := exclusion(ctx, concurrencyLimit, falseHandler, falseHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
 		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+1))
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("true_butnot_err_return_err", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, trueHandler, generalErrorHandler)
+		require.EqualError(t, err, simulatedDBErrorMessage)
+		require.Nil(t, resp)
+	})
+
+	t.Run("true_butnot_errResolutionDepth_return_errResolutionDepth", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, trueHandler, depthExceededHandler)
+		require.ErrorIs(t, err, ErrResolutionDepthExceeded)
+		require.Nil(t, resp)
 	})
 
-	t.Run("base_handler_is_falsy_subtract_handler_with_error_return_allowed:false", func(t *testing.T) {
+	t.Run("true_butnot_cycle_return_false", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, trueHandler, cyclicErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
+	})
+
+	t.Run("false_butnot_err_return_false", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, falseHandler, generalErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("false_butnot_errResolutionDepth_return_false", func(t *testing.T) {
 		resp, err := exclusion(ctx, concurrencyLimit, falseHandler, depthExceededHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("false_butnot_cycle_return_false", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, falseHandler, cyclicErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
-		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+2))
 	})
 
-	t.Run("base_handler_with_error_subtract_handler_is_truthy_return_allowed:false", func(t *testing.T) {
+	t.Run("err_butnot_true_return_false", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, generalErrorHandler, trueHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("errResolutionDepth_butnot_true_return_false", func(t *testing.T) {
 		resp, err := exclusion(ctx, concurrencyLimit, depthExceededHandler, trueHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
-		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(2+1))
+		require.False(t, resp.GetCycleDetected())
 	})
 
-	t.Run("base_handler_with_error_subtract_handler_with_error_return_error", func(t *testing.T) {
-		resp, err := exclusion(ctx, concurrencyLimit, depthExceededHandler, depthExceededHandler)
-		require.ErrorIs(t, err, ErrResolutionDepthExceeded)
+	t.Run("cycle_butnot_true_return_false", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, cyclicErrorHandler, trueHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
-		require.Equal(t, uint32(0), resp.GetResolutionMetadata().DatastoreQueryCount)
 	})
 
-	t.Run("base_handler_with_cycle_subtract_handler_is_falsy_return_allowed:false_with_a_nil_error", func(t *testing.T) {
+	t.Run("err_butnot_false_return_err", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, generalErrorHandler, falseHandler)
+		require.EqualError(t, err, simulatedDBErrorMessage)
+		require.Nil(t, resp)
+	})
+
+	t.Run("errResolutionDepth_butnot_false_return_errResolutionDepth", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, depthExceededHandler, falseHandler)
+		require.ErrorIs(t, err, ErrResolutionDepthExceeded)
+		require.Nil(t, resp)
+	})
+
+	t.Run("cycle_butnot_false_return_false", func(t *testing.T) {
 		resp, err := exclusion(ctx, concurrencyLimit, cyclicErrorHandler, falseHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
-		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(2+1))
+		require.True(t, resp.GetCycleDetected())
 	})
 
-	t.Run("base_handler_with_cycle_subtract_handler_is_truthy_return_allowed:false_with_a_nil_error", func(t *testing.T) {
-		resp, err := exclusion(ctx, concurrencyLimit, cyclicErrorHandler, trueHandler)
+	t.Run("cycle_butnot_err_return_false", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, cyclicErrorHandler, generalErrorHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
-		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(2+1))
+		require.True(t, resp.GetCycleDetected())
 	})
 
-	t.Run("base_handler_is_falsy_subtract_handler_with_cycle_return_allowed:false_with_a_nil_error", func(t *testing.T) {
-		resp, err := exclusion(ctx, concurrencyLimit, falseHandler, cyclicErrorHandler)
+	t.Run("cycle_butnot_errResolutionDepth_return_false", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, cyclicErrorHandler, depthExceededHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
-		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+2))
+		require.True(t, resp.GetCycleDetected())
 	})
 
-	t.Run("base_handler_is_truthy_subtract_handler_with_cycle_return_allowed:true_with_a_nil_error", func(t *testing.T) {
-		resp, err := exclusion(ctx, concurrencyLimit, trueHandler, cyclicErrorHandler)
+	t.Run("err_butnot_cycle_return_false", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, generalErrorHandler, cyclicErrorHandler)
 		require.NoError(t, err)
-		require.True(t, resp.GetAllowed())
-		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+2))
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
+	})
+
+	t.Run("errResolutionDepth_butnot_cycle_return_false", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, depthExceededHandler, cyclicErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
 	})
 
-	t.Run("base_handler_with_cycle_subtract_handler_with_cycle_return_allowed:false_with_a_nil_error", func(t *testing.T) {
+	t.Run("cycle_butnot_cycle_return_false", func(t *testing.T) {
 		resp, err := exclusion(ctx, concurrencyLimit, cyclicErrorHandler, cyclicErrorHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
-		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(2+2))
+		require.True(t, resp.GetCycleDetected())
+	})
+
+	t.Run("err_butnot_err_return_err", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, generalErrorHandler, generalErrorHandler)
+		require.ErrorContains(t, err, simulatedDBErrorMessage)
+		require.Nil(t, resp)
+	})
+
+	t.Run("errResolutionDepth_butnot_errResolutionDepth_return_errResolutionDepth", func(t *testing.T) {
+		resp, err := exclusion(ctx, concurrencyLimit, depthExceededHandler, depthExceededHandler)
+		require.ErrorIs(t, err, ErrResolutionDepthExceeded)
+		require.Nil(t, resp)
 	})
 
 	t.Run("aggregate_truthy_and_falsy_handlers_datastore_query_count", func(t *testing.T) {
@@ -176,6 +300,54 @@ func TestExclusionCheckFuncReducer(t *testing.T) {
 		wg.Wait() // just to make sure to avoid test leaks
 	})
 
+	t.Run("return_allowed:false_if_sub_handler_evaluated_before_context_cancelled", func(t *testing.T) {
+		ctx, cancel := context.WithCancel(context.Background())
+		t.Cleanup(cancel)
+
+		var wg sync.WaitGroup
+
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			time.Sleep(10 * time.Millisecond)
+			cancel()
+		}()
+
+		resp, err := exclusion(ctx, concurrencyLimit, trueHandler, trueHandler)
+		require.NoError(t, err)
+		require.False(t, resp.GetAllowed())
+
+		wg.Wait() // just to make sure to avoid test leaks
+	})
+
+	t.Run("return_allowed:false_if_sub_handler_evaluated_before_base_cancelled", func(t *testing.T) {
+		ctx, cancel := context.WithCancel(context.Background())
+		t.Cleanup(cancel)
+
+		var wg sync.WaitGroup
+
+		slowTrueHandler := func(context.Context) (*ResolveCheckResponse, error) {
+			time.Sleep(50 * time.Millisecond)
+
+			return &ResolveCheckResponse{
+				Allowed: true,
+			}, nil
+		}
+
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			time.Sleep(10 * time.Millisecond)
+			cancel()
+		}()
+
+		resp, err := exclusion(ctx, concurrencyLimit, slowTrueHandler, trueHandler)
+		require.NoError(t, err)
+		require.False(t, resp.GetAllowed())
+
+		wg.Wait() // just to make sure to avoid test leaks
+	})
+
 	t.Run("return_allowed:false_if_subtract_handler_evaluated_before_context_cancelled", func(t *testing.T) {
 		ctx, cancel := context.WithCancel(context.Background())
 		t.Cleanup(cancel)
@@ -200,14 +372,23 @@ func TestExclusionCheckFuncReducer(t *testing.T) {
 		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
 		t.Cleanup(cancel)
 
-		slowHandler := func(context.Context) (*ResolveCheckResponse, error) {
+		slowTrueHandler := func(context.Context) (*ResolveCheckResponse, error) {
 			time.Sleep(50 * time.Millisecond)
-			return nil, nil
+			return &ResolveCheckResponse{
+				Allowed: true,
+			}, nil
 		}
 
-		resp, err := exclusion(ctx, concurrencyLimit, slowHandler, slowHandler)
+		slowFalseHandler := func(context.Context) (*ResolveCheckResponse, error) {
+			time.Sleep(50 * time.Millisecond)
+			return &ResolveCheckResponse{
+				Allowed: false,
+			}, nil
+		}
+
+		resp, err := exclusion(ctx, concurrencyLimit, slowTrueHandler, slowFalseHandler)
 		require.ErrorIs(t, err, context.DeadlineExceeded)
-		require.False(t, resp.GetAllowed())
+		require.Nil(t, resp)
 	})
 
 	t.Run("return_error_if_context_cancelled_before_resolution", func(t *testing.T) {
@@ -225,12 +406,12 @@ func TestExclusionCheckFuncReducer(t *testing.T) {
 
 		slowHandler := func(context.Context) (*ResolveCheckResponse, error) {
 			time.Sleep(50 * time.Millisecond)
-			return nil, nil
+			return &ResolveCheckResponse{}, nil
 		}
 
 		resp, err := exclusion(ctx, concurrencyLimit, slowHandler, slowHandler)
 		require.ErrorIs(t, err, context.Canceled)
-		require.False(t, resp.GetAllowed())
+		require.Nil(t, resp)
 
 		wg.Wait() // just to make sure to avoid test leaks
 	})
@@ -245,70 +426,198 @@ func TestIntersectionCheckFuncReducer(t *testing.T) {
 
 	concurrencyLimit := uint32(10)
 
-	t.Run("no_handlers_return_allowed:false", func(t *testing.T) {
+	t.Run("no_handlers_return_false", func(t *testing.T) {
 		resp, err := intersection(ctx, concurrencyLimit)
 		require.NoError(t, err)
 		require.False(t, resp.GetAllowed())
 		require.NotNil(t, resp.GetResolutionMetadata())
+		require.False(t, resp.GetCycleDetected())
 	})
 
-	t.Run("one_handler_is_falsy_return_allowed:false", func(t *testing.T) {
+	t.Run("false_return_false", func(t *testing.T) {
 		resp, err := intersection(ctx, concurrencyLimit, falseHandler)
 		require.NoError(t, err)
 		require.False(t, resp.GetAllowed())
 		require.Equal(t, uint32(1), resp.GetResolutionMetadata().DatastoreQueryCount)
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("true_and_true_return_true", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, trueHandler, trueHandler)
+		require.NoError(t, err)
+		require.True(t, resp.GetAllowed())
+		require.Equal(t, uint32(2), resp.GetResolutionMetadata().DatastoreQueryCount)
+		require.False(t, resp.GetCycleDetected())
 	})
 
-	t.Run("one_handler_is_truthy_and_others_are_falsy_return_allowed:false", func(t *testing.T) {
-		resp, err := intersection(ctx, concurrencyLimit, trueHandler, falseHandler, falseHandler)
+	t.Run("true_and_false_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, trueHandler, falseHandler)
 		require.NoError(t, err)
 		require.False(t, resp.GetAllowed())
 		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(2))
+		require.False(t, resp.GetCycleDetected())
 	})
 
-	t.Run("all_handlers_are_falsy_return_allowed:false", func(t *testing.T) {
-		resp, err := intersection(ctx, concurrencyLimit, falseHandler, falseHandler, falseHandler)
+	t.Run("false_and_true_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, falseHandler, trueHandler)
+		require.NoError(t, err)
+		require.False(t, resp.GetAllowed())
+		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(2))
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("false_and_false_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, falseHandler, falseHandler)
 		require.NoError(t, err)
 		require.False(t, resp.GetAllowed())
 		require.Equal(t, uint32(1), resp.GetResolutionMetadata().DatastoreQueryCount)
+		require.False(t, resp.GetCycleDetected())
 	})
 
-	t.Run("all_handlers_with_error_return_error", func(t *testing.T) {
-		_, err := intersection(ctx, concurrencyLimit, depthExceededHandler, depthExceededHandler)
+	t.Run("true_and_err_return_err", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, trueHandler, generalErrorHandler)
+		require.EqualError(t, err, simulatedDBErrorMessage)
+		require.Nil(t, resp)
+	})
+
+	t.Run("true_and_errResolutionDepth_return_err", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, trueHandler, depthExceededHandler)
 		require.ErrorIs(t, err, ErrResolutionDepthExceeded)
+		require.Nil(t, resp)
+	})
+
+	t.Run("true_and_cycle_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, trueHandler, cyclicErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
+	})
+
+	t.Run("false_and_err_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, falseHandler, generalErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("false_and_errResolutionDepth_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, falseHandler, depthExceededHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("false_and_cycle_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, falseHandler, cyclicErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+	})
+
+	t.Run("err_and_true_return_err", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, generalErrorHandler, trueHandler)
+		require.EqualError(t, err, simulatedDBErrorMessage)
+		require.Nil(t, resp)
 	})
 
-	t.Run("one_handler_returns_error_but_other_handler_is_truthy_return_error_and_allowed:false", func(t *testing.T) {
-		_, err := intersection(ctx, concurrencyLimit, depthExceededHandler, trueHandler)
+	t.Run("errResolutionDepth_and_true_return_err", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, depthExceededHandler, trueHandler)
 		require.ErrorIs(t, err, ErrResolutionDepthExceeded)
+		require.Nil(t, resp)
 	})
 
-	t.Run("one_handler_returns_error_but_other_handler_is_falsy_return_allowed:false_with_a_nil_error", func(t *testing.T) {
+	t.Run("cycle_and_true_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, trueHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
+	})
+
+	t.Run("err_and_false_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, generalErrorHandler, falseHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("errResolutionDepth_and_false_return_false", func(t *testing.T) {
 		resp, err := intersection(ctx, concurrencyLimit, depthExceededHandler, falseHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
-		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+2))
+		require.False(t, resp.GetCycleDetected())
 	})
 
-	t.Run("one_handler_errors_with_cycle_but_other_handler_is_falsy_return_allowed:false_with_a_nil_error", func(t *testing.T) {
+	t.Run("cycle_and_false_return_false", func(t *testing.T) {
 		resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, falseHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
-		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+2))
 	})
 
-	t.Run("one_handler_errors_with_cycle_but_other_handler_is_truthy_return_allowed:false_with_a_nil_error", func(t *testing.T) {
-		resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, trueHandler)
+	t.Run("cycle_and_err_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, generalErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
+	})
+
+	t.Run("cycle_and_errResolutionDepth_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, depthExceededHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
+	})
+
+	t.Run("err_and_cycle_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, generalErrorHandler, cyclicErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
+	})
+
+	t.Run("errResolutionDepth_and_cycle_return_false", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, depthExceededHandler, cyclicErrorHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
-		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1))
+		require.True(t, resp.GetCycleDetected())
 	})
 
-	t.Run("both_handlers_errors_with_cycle_return_allowed:false_with_a_nil_error", func(t *testing.T) {
+	t.Run("cycle_and_cycle_return_false", func(t *testing.T) {
 		resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, cyclicErrorHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
-		require.Equal(t, uint32(0), resp.GetResolutionMetadata().DatastoreQueryCount)
+		require.True(t, resp.GetCycleDetected())
+	})
+
+	t.Run("err_and_err_return_err", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, generalErrorHandler, generalErrorHandler)
+		require.ErrorContains(t, err, simulatedDBErrorMessage)
+		require.Nil(t, resp)
+	})
+
+	t.Run("errResolutionDepth_and_errResolutionDepth_return_errResolutionDepth", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, depthExceededHandler, depthExceededHandler)
+		require.ErrorIs(t, err, ErrResolutionDepthExceeded)
+		require.Nil(t, resp)
+	})
+
+	t.Run("true_and_cycle_and_err_return_err", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, trueHandler, cyclicErrorHandler, generalErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
 	})
 
 	t.Run("aggregate_truthy_and_falsy_handlers_datastore_query_count", func(t *testing.T) {
@@ -318,6 +627,13 @@ func TestIntersectionCheckFuncReducer(t *testing.T) {
 		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+1))
 	})
 
+	t.Run("cycle_and_false_reports_correct_datastore_query_count", func(t *testing.T) {
+		resp, err := intersection(ctx, concurrencyLimit, cyclicErrorHandler, falseHandler)
+		require.NoError(t, err)
+		require.False(t, resp.GetAllowed())
+		require.LessOrEqual(t, resp.GetResolutionMetadata().DatastoreQueryCount, uint32(1+1))
+	})
+
 	t.Run("return_allowed:false_if_falsy_handler_evaluated_before_context_deadline", func(t *testing.T) {
 		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
 		t.Cleanup(cancel)
@@ -327,6 +643,31 @@ func TestIntersectionCheckFuncReducer(t *testing.T) {
 		require.False(t, resp.GetAllowed())
 	})
 
+	t.Run("return_true_if_truthy_handler_evaluated_before_context_deadline", func(t *testing.T) {
+		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
+		t.Cleanup(cancel)
+
+		resp, err := intersection(ctx, concurrencyLimit, trueHandler)
+		require.NoError(t, err)
+		require.True(t, resp.GetAllowed())
+	})
+
+	t.Run("return_error_if_context_deadline_before_truthy_handler", func(t *testing.T) {
+		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
+		t.Cleanup(cancel)
+
+		slowTrueHandler := func(context.Context) (*ResolveCheckResponse, error) {
+			time.Sleep(50 * time.Millisecond)
+			return &ResolveCheckResponse{
+				Allowed: true,
+			}, nil
+		}
+
+		resp, err := intersection(ctx, concurrencyLimit, slowTrueHandler)
+		require.ErrorIs(t, err, context.DeadlineExceeded)
+		require.Nil(t, resp)
+	})
+
 	t.Run("return_allowed:false_if_falsy_handler_evaluated_before_context_cancelled", func(t *testing.T) {
 		ctx, cancel := context.WithCancel(context.Background())
 		t.Cleanup(cancel)
@@ -351,14 +692,16 @@ func TestIntersectionCheckFuncReducer(t *testing.T) {
 		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
 		t.Cleanup(cancel)
 
-		slowHandler := func(context.Context) (*ResolveCheckResponse, error) {
+		slowTrueHandler := func(context.Context) (*ResolveCheckResponse, error) {
 			time.Sleep(50 * time.Millisecond)
-			return nil, nil
+			return &ResolveCheckResponse{
+				Allowed: true,
+			}, nil
 		}
 
-		resp, err := intersection(ctx, concurrencyLimit, slowHandler)
+		resp, err := intersection(ctx, concurrencyLimit, slowTrueHandler)
 		require.ErrorIs(t, err, context.DeadlineExceeded)
-		require.False(t, resp.GetAllowed())
+		require.Nil(t, resp)
 	})
 
 	t.Run("return_error_if_context_cancelled_before_resolution", func(t *testing.T) {
@@ -374,19 +717,105 @@ func TestIntersectionCheckFuncReducer(t *testing.T) {
 			cancel()
 		}()
 
-		slowHandler := func(context.Context) (*ResolveCheckResponse, error) {
+		slowTrueHandler := func(context.Context) (*ResolveCheckResponse, error) {
 			time.Sleep(50 * time.Millisecond)
-			return nil, nil
+			return &ResolveCheckResponse{
+				Allowed: true,
+			}, nil
 		}
 
-		resp, err := intersection(ctx, concurrencyLimit, slowHandler)
+		resp, err := intersection(ctx, concurrencyLimit, slowTrueHandler)
 		require.ErrorIs(t, err, context.Canceled)
-		require.False(t, resp.GetAllowed())
+		require.Nil(t, resp)
 
 		wg.Wait() // just to make sure to avoid test leaks
 	})
 }
 
+func TestNonStratifiableCheckQueries(t *testing.T) {
+	t.Run("example_1", func(t *testing.T) {
+		ds := memory.New()
+
+		storeID := ulid.Make().String()
+
+		err := ds.Write(context.Background(), storeID, nil, []*openfgav1.TupleKey{
+			tuple.NewTupleKey("document:1", "viewer", "user:jon"),
+			tuple.NewTupleKey("document:1", "restricted", "document:1#viewer"),
+		})
+		require.NoError(t, err)
+
+		checker := NewLocalCheckerWithCycleDetection()
+		t.Cleanup(checker.Close)
+
+		model := testutils.MustTransformDSLToProtoWithID(`model
+	schema 1.1
+	type user
+
+
+	type document
+	  relations
+		define viewer: [user] but not restricted
+		define restricted: [user, document#viewer]`)
+
+		ctx := typesystem.ContextWithTypesystem(
+			context.Background(),
+			typesystem.New(model),
+		)
+
+		ctx = storage.ContextWithRelationshipTupleReader(ctx, ds)
+
+		resp, err := checker.ResolveCheck(ctx, &ResolveCheckRequest{
+			StoreID:         storeID,
+			TupleKey:        tuple.NewTupleKey("document:1", "viewer", "user:jon"),
+			RequestMetadata: NewCheckRequestMetadata(10),
+		})
+		require.NoError(t, err)
+		require.False(t, resp.GetAllowed())
+	})
+
+	t.Run("example_2", func(t *testing.T) {
+		ds := memory.New()
+
+		storeID := ulid.Make().String()
+
+		err := ds.Write(context.Background(), storeID, nil, []*openfgav1.TupleKey{
+			tuple.NewTupleKey("document:1", "viewer", "user:jon"),
+			tuple.NewTupleKey("document:1", "restrictedb", "document:1#viewer"),
+		})
+		require.NoError(t, err)
+
+		checker := NewLocalCheckerWithCycleDetection()
+		t.Cleanup(checker.Close)
+
+		model := testutils.MustTransformDSLToProtoWithID(`model
+	schema 1.1
+	type user
+
+
+	type document
+	  relations
+		define viewer: [user] but not restricteda
+		define restricteda: restrictedb
+		define restrictedb: [user, document#viewer]
+	`)
+
+		ctx := typesystem.ContextWithTypesystem(
+			context.Background(),
+			typesystem.New(model),
+		)
+
+		ctx = storage.ContextWithRelationshipTupleReader(ctx, ds)
+
+		resp, err := checker.ResolveCheck(ctx, &ResolveCheckRequest{
+			StoreID:         storeID,
+			TupleKey:        tuple.NewTupleKey("document:1", "viewer", "user:jon"),
+			RequestMetadata: NewCheckRequestMetadata(10),
+		})
+		require.NoError(t, err)
+		require.False(t, resp.GetAllowed())
+	})
+}
+
 func TestResolveCheckDeterministic(t *testing.T) {
 	t.Run("resolution_depth_resolves_deterministically", func(t *testing.T) {
 		t.Parallel()
@@ -859,101 +1288,6 @@ type document
 	}
 }
 
-// TestCheckWithUnexpectedCycle tests the LocalChecker to make sure that if a model includes a cycle
-// that should have otherwise been invalid according to the typesystem, then the check resolution will
-// consider the cycle a falsey allowed result and not bubble-up a cycle detected error.
-func TestCheckWithUnexpectedCycle(t *testing.T) {
-	ds := memory.New()
-	defer ds.Close()
-
-	storeID := ulid.Make().String()
-
-	err := ds.Write(context.Background(), storeID, nil, []*openfgav1.TupleKey{
-		tuple.NewTupleKey("resource:1", "parent", "resource:1"),
-	})
-	require.NoError(t, err)
-
-	tests := []struct {
-		name     string
-		model    string
-		tupleKey *openfgav1.TupleKey
-	}{
-		{
-			name: "test_1",
-			model: `model
-	schema 1.1
-type user
-
-type resource
-  relations
-	define x: [user] but not y
-	define y: [user] but not z
-	define z: [user] or x`,
-			tupleKey: tuple.NewTupleKey("resource:1", "x", "user:jon"),
-		},
-		{
-			name: "test_2",
-			model: `model
-	schema 1.1
-type user
-
-type resource
-  relations
-	define x: [user] and y
-	define y: [user] and z
-	define z: [user] or x`,
-			tupleKey: tuple.NewTupleKey("resource:1", "x", "user:jon"),
-		},
-		{
-			name: "test_3",
-			model: `model
-	schema 1.1
-type resource
-  relations
-	define x: y
-	define y: x`,
-			tupleKey: tuple.NewTupleKey("resource:1", "x", "user:jon"),
-		},
-		{
-			name: "test_4",
-			model: `model
-	schema 1.1
-type resource
-  relations
-	define parent: [resource]
-	define x: [user] or x from parent`,
-			tupleKey: tuple.NewTupleKey("resource:1", "x", "user:jon"),
-		},
-	}
-
-	checker := NewLocalCheckerWithCycleDetection()
-	t.Cleanup(checker.Close)
-
-	for _, test := range tests {
-		t.Run(test.name, func(t *testing.T) {
-			model := testutils.MustTransformDSLToProtoWithID(test.model)
-
-			ctx := typesystem.ContextWithTypesystem(
-				context.Background(),
-				typesystem.New(model),
-			)
-			ctx = storage.ContextWithRelationshipTupleReader(ctx, ds)
-
-			resp, err := checker.ResolveCheck(ctx, &ResolveCheckRequest{
-				StoreID:         storeID,
-				TupleKey:        test.tupleKey,
-				RequestMetadata: NewCheckRequestMetadata(25),
-			})
-
-			require.NoError(t, err)
-			require.False(t, resp.GetAllowed())
-
-			require.GreaterOrEqual(t, resp.ResolutionMetadata.DatastoreQueryCount, uint32(0)) // min of 0 (x) if x is cycle. TODO: accurately report datastore query count of cycle branches
-			require.LessOrEqual(t, resp.ResolutionMetadata.DatastoreQueryCount, uint32(3))    // max of 3 (x, y, z) before the cycle
-		})
-	}
-}
-
 func TestCheckConditions(t *testing.T) {
 	ds := memory.New()
 
@@ -1122,9 +1456,9 @@ type doc
 
 		model := parser.MustTransformDSLToProto(`model
 	  schema 1.1
-	
+
 	type user
-	
+
 	type group
 	  relations
 	    define member: [user, group#member]
@@ -1187,7 +1521,7 @@ type doc
 		schema 1.1
 
 		type user
-  	
+
 		type document
 			relations
 		   		define owner: [user]
@@ -1270,75 +1604,177 @@ func TestUnionCheckFuncReducer(t *testing.T) {
 		}, nil
 	}
 
-	t.Run("if_no_handlers_return_allowed_false", func(t *testing.T) {
+	t.Run("no_handlers_return_allowed_false", func(t *testing.T) {
 		resp, err := union(ctx, concurrencyLimit)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
 	})
 
-	t.Run("if_one_handler_is_truthy_and_others_are_falsey_return_allowed_true", func(t *testing.T) {
-		resp, err := union(ctx, concurrencyLimit, trueHandler, falseHandler, falseHandler)
+	t.Run("true_or_true_return_true", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, trueHandler, trueHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.True(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
 	})
 
-	t.Run("if_all_handlers_are_falsey_return_allowed_false", func(t *testing.T) {
-		resp, err := union(ctx, concurrencyLimit, falseHandler, falseHandler, falseHandler)
+	t.Run("true_or_false_return_true", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, trueHandler, falseHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.True(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("false_or_true_return_true", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, falseHandler, trueHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.True(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("false_or_false_return_false", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, falseHandler, falseHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
 	})
 
-	t.Run("if_a_handler_errors_but_other_handler_are_truthy_return_allowed_true", func(t *testing.T) {
-		depthExceededHandler := func(context.Context) (*ResolveCheckResponse, error) {
-			return nil, ErrResolutionDepthExceeded
-		}
+	t.Run("true_or_err_return_true", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, trueHandler, generalErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.True(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
+	})
 
-		resp, err := union(ctx, concurrencyLimit, depthExceededHandler, trueHandler)
+	t.Run("true_or_errResolutionDepth_return_true", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, trueHandler, depthExceededHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.True(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
 	})
 
-	t.Run("if_a_handler_errors_but_other_handler_is_falsey_return_error_and_allowed_false", func(t *testing.T) {
-		depthExceededHandler := func(context.Context) (*ResolveCheckResponse, error) {
-			return nil, ErrResolutionDepthExceeded
-		}
+	t.Run("true_or_cycle_return_true", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, trueHandler, cyclicErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.True(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
+	})
 
-		resp, err := union(ctx, concurrencyLimit, depthExceededHandler, falseHandler)
-		require.ErrorIs(t, ErrResolutionDepthExceeded, err)
+	t.Run("false_or_err_return_err", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, falseHandler, generalErrorHandler)
+		require.EqualError(t, err, simulatedDBErrorMessage)
+		require.Nil(t, resp)
+	})
+
+	t.Run("false_or_errResolutionDepth_return_err", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, falseHandler, depthExceededHandler)
+		require.ErrorIs(t, err, ErrResolutionDepthExceeded)
+		require.Nil(t, resp)
+	})
+
+	t.Run("false_or_cycle_return_false", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, falseHandler, cyclicErrorHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
 	})
 
-	t.Run("if_a_handler_errors_with_cycle_but_other_handler_is_falsey_return_allowed_false_with_a_nil_error", func(t *testing.T) {
-		cyclicErrorHandler := func(context.Context) (*ResolveCheckResponse, error) {
-			return nil, ErrCycleDetected
-		}
+	t.Run("err_or_true_return_true", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, generalErrorHandler, trueHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.True(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("cycle_or_true_return_true", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, cyclicErrorHandler, trueHandler)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
+		require.True(t, resp.GetAllowed())
+		require.False(t, resp.GetCycleDetected())
+	})
+
+	t.Run("err_or_false_return_err", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, generalErrorHandler, falseHandler)
+		require.EqualError(t, err, simulatedDBErrorMessage)
+		require.Nil(t, resp)
+	})
+
+	t.Run("errResolutionDepth_or_false_return_err", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, depthExceededHandler, falseHandler)
+		require.ErrorIs(t, err, ErrResolutionDepthExceeded)
+		require.Nil(t, resp)
+	})
 
+	t.Run("cycle_or_false_return_false", func(t *testing.T) {
 		resp, err := union(ctx, concurrencyLimit, cyclicErrorHandler, falseHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
 	})
 
-	t.Run("both_handlers_with_cycle_return_allowed_false_with_nil_error", func(t *testing.T) {
-		cyclicErrorHandler := func(context.Context) (*ResolveCheckResponse, error) {
-			return nil, ErrCycleDetected
-		}
+	t.Run("cycle_or_err_return_err", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, cyclicErrorHandler, generalErrorHandler)
+		require.EqualError(t, err, simulatedDBErrorMessage)
+		require.Nil(t, resp)
+	})
+
+	t.Run("cycle_or_errResolutionDepth_return_err", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, cyclicErrorHandler, depthExceededHandler)
+		require.ErrorIs(t, err, ErrResolutionDepthExceeded)
+		require.Nil(t, resp)
+	})
+
+	t.Run("err_or_cycle_return_err", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, generalErrorHandler, cyclicErrorHandler)
+		require.EqualError(t, err, simulatedDBErrorMessage)
+		require.Nil(t, resp)
+	})
+
+	t.Run("errResolutionDepth_or_cycle_return_err", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, depthExceededHandler, cyclicErrorHandler)
+		require.ErrorIs(t, err, ErrResolutionDepthExceeded)
+		require.Nil(t, resp)
+	})
 
+	t.Run("cycle_or_cycle_return_false", func(t *testing.T) {
 		resp, err := union(ctx, concurrencyLimit, cyclicErrorHandler, cyclicErrorHandler)
 		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
 	})
 
-	t.Run("if_a_handler_errors_with_cycle_but_other_handler_is_truthy_return_allowed_true_with_a_nil_error", func(t *testing.T) {
-		cyclicErrorHandler := func(context.Context) (*ResolveCheckResponse, error) {
-			return nil, ErrCycleDetected
-		}
+	t.Run("false_or_cycle_or_err", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, falseHandler, cyclicErrorHandler, generalErrorHandler)
+		require.EqualError(t, err, simulatedDBErrorMessage)
+		require.Nil(t, resp)
+	})
 
-		resp, err := union(ctx, concurrencyLimit, cyclicErrorHandler, trueHandler)
-		require.NoError(t, err)
-		require.True(t, resp.GetAllowed())
+	t.Run("err_or_err_return_err", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, falseHandler, generalErrorHandler, generalErrorHandler)
+		require.ErrorContains(t, err, simulatedDBErrorMessage)
+		require.Nil(t, resp)
 	})
 
-	t.Run("should_aggregate_DatastoreQueryCount_of_non_error handlers", func(t *testing.T) {
+	t.Run("errResolutionDepth_or_errResolutionDepth_return_errResolutionDepth", func(t *testing.T) {
+		resp, err := union(ctx, concurrencyLimit, falseHandler, depthExceededHandler, depthExceededHandler)
+		require.ErrorIs(t, err, ErrResolutionDepthExceeded)
+		require.Nil(t, resp)
+	})
+
+	t.Run("should_aggregate_DatastoreQueryCount_of_non_error_handlers", func(t *testing.T) {
 		trueHandler := func(context.Context) (*ResolveCheckResponse, error) {
 			time.Sleep(5 * time.Millisecond) // forces `trueHandler` to be resolved after `falseHandler`
 			return &ResolveCheckResponse{
@@ -1402,12 +1838,12 @@ func TestUnionCheckFuncReducer(t *testing.T) {
 
 		slowHandler := func(context.Context) (*ResolveCheckResponse, error) {
 			time.Sleep(50 * time.Millisecond)
-			return nil, nil
+			return &ResolveCheckResponse{}, nil
 		}
 
 		resp, err := union(ctx, concurrencyLimit, slowHandler, falseHandler)
 		require.ErrorIs(t, err, context.DeadlineExceeded)
-		require.False(t, resp.GetAllowed())
+		require.Nil(t, resp)
 	})
 
 	t.Run("should_handle_context_timeouts_even_with_eventual_truthy_handler", func(t *testing.T) {
@@ -1423,6 +1859,103 @@ func TestUnionCheckFuncReducer(t *testing.T) {
 
 		resp, err := union(ctx, concurrencyLimit, trueHandler, falseHandler)
 		require.ErrorIs(t, err, context.DeadlineExceeded)
-		require.False(t, resp.GetAllowed())
+		require.Nil(t, resp)
 	})
+
+	t.Run("should_return_true_with_slow_falsey_handler", func(t *testing.T) {
+		ctx, cancel := context.WithTimeout(ctx, 10*time.Millisecond)
+		t.Cleanup(cancel)
+
+		falseSlowHandler := func(context.Context) (*ResolveCheckResponse, error) {
+			time.Sleep(25 * time.Millisecond)
+			return &ResolveCheckResponse{
+				Allowed: false,
+			}, nil
+		}
+
+		resp, err := union(ctx, concurrencyLimit, trueHandler, falseSlowHandler)
+		require.NoError(t, err)
+		require.True(t, resp.GetAllowed())
+	})
+
+	t.Run("return_error_if_context_cancelled_before_resolution", func(t *testing.T) {
+		ctx, cancel := context.WithCancel(context.Background())
+		t.Cleanup(cancel)
+
+		var wg sync.WaitGroup
+
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			time.Sleep(10 * time.Millisecond)
+			cancel()
+		}()
+
+		slowTrueHandler := func(context.Context) (*ResolveCheckResponse, error) {
+			time.Sleep(50 * time.Millisecond)
+			return &ResolveCheckResponse{
+				Allowed: true,
+			}, nil
+		}
+
+		resp, err := union(ctx, concurrencyLimit, slowTrueHandler)
+		require.ErrorIs(t, err, context.Canceled)
+		require.Nil(t, resp)
+
+		wg.Wait() // just to make sure to avoid test leaks
+	})
+
+	t.Run("return_allowed:true_if_truthy_handler_evaluated_before_context_cancelled", func(t *testing.T) {
+		ctx, cancel := context.WithCancel(context.Background())
+		t.Cleanup(cancel)
+
+		var wg sync.WaitGroup
+
+		wg.Add(1)
+		go func() {
+			defer wg.Done()
+			time.Sleep(10 * time.Millisecond)
+			cancel()
+		}()
+
+		resp, err := intersection(ctx, concurrencyLimit, trueHandler)
+		require.NoError(t, err)
+		require.True(t, resp.GetAllowed())
+
+		wg.Wait() // just to make sure to avoid test leaks
+	})
+}
+
+func TestCloneResolveCheckResponse(t *testing.T) {
+	resp1 := &ResolveCheckResponse{
+		Allowed: true,
+		ResolutionMetadata: &ResolveCheckResponseMetadata{
+			DatastoreQueryCount: 1,
+			CycleDetected:       false,
+		},
+	}
+	clonedResp1 := CloneResolveCheckResponse(resp1)
+
+	require.Equal(t, resp1, clonedResp1)
+	require.NotSame(t, resp1, clonedResp1)
+
+	// mutate the clone and ensure the original reference is
+	// unchanged
+	clonedResp1.Allowed = false
+	clonedResp1.ResolutionMetadata.DatastoreQueryCount = 2
+	clonedResp1.ResolutionMetadata.CycleDetected = true
+	require.True(t, resp1.GetAllowed())
+	require.Equal(t, uint32(1), resp1.GetResolutionMetadata().DatastoreQueryCount)
+	require.False(t, resp1.GetResolutionMetadata().CycleDetected)
+
+	resp2 := &ResolveCheckResponse{
+		Allowed: true,
+	}
+	clonedResp2 := CloneResolveCheckResponse(resp2)
+
+	require.NotSame(t, resp2, clonedResp2)
+	require.Equal(t, resp2.GetAllowed(), clonedResp2.GetAllowed())
+	require.NotNil(t, clonedResp2.ResolutionMetadata)
+	require.Equal(t, uint32(0), clonedResp2.GetResolutionMetadata().DatastoreQueryCount)
+	require.False(t, clonedResp2.GetResolutionMetadata().CycleDetected)
 }

programming_language: Go
filename: cycle_check_resolver.go
change_type: ModificationType.MODIFY
diff: @@ -43,7 +43,12 @@ func (c *CycleDetectionCheckResolver) ResolveCheck(
 	_, cycleDetected := req.VisitedPaths[key]
 	span.SetAttributes(attribute.Bool("cycle_detected", cycleDetected))
 	if cycleDetected {
-		return nil, ErrCycleDetected
+		return &ResolveCheckResponse{
+			Allowed: false,
+			ResolutionMetadata: &ResolveCheckResponseMetadata{
+				CycleDetected: true,
+			},
+		}, nil
 	}
 
 	req.VisitedPaths[key] = struct{}{}

programming_language: Go
filename: cycle_check_resolver_test.go
change_type: ModificationType.MODIFY
diff: @@ -42,8 +42,11 @@ func TestCycleDetectionCheckResolver(t *testing.T) {
 			VisitedPaths:    visitedPaths,
 		})
 
-		require.ErrorIs(t, err, ErrCycleDetected)
+		require.NoError(t, err)
+		require.NotNil(t, resp)
 		require.False(t, resp.GetAllowed())
+		require.True(t, resp.GetCycleDetected())
+		require.NotNil(t, resp.ResolutionMetadata)
 	})
 
 	t.Run("no_cycle_detected_delegates_request", func(t *testing.T) {
@@ -119,5 +122,6 @@ func TestIntegrationWithLocalChecker(t *testing.T) {
 		RequestMetadata:      NewCheckRequestMetadata(25),
 	})
 	require.NoError(t, err)
-	require.True(t, resp.GetAllowed())
+	require.NotNil(t, resp)
+	require.False(t, resp.GetAllowed())
 }

programming_language: Go
filename: graph.go
change_type: ModificationType.MODIFY
diff: @@ -73,6 +73,10 @@ type ResolveCheckResponseMetadata struct {
 	// evaluated and potentially discarded
 	// If the solution is "allowed=false", no paths were found. This is the sum of all the reads in all the paths that had to be evaluated
 	DatastoreQueryCount uint32
+
+	// Indicates if the ResolveCheck subproblem that was evaluated involved
+	// a cycle in the evaluation.
+	CycleDetected bool
 }
 
 type RelationshipEdgeType int

programming_language: Go
filename: list_objects.go
change_type: ModificationType.MODIFY
diff: @@ -333,7 +333,7 @@ func (q *ListObjectsQuery) evaluate(
 						RequestMetadata:      checkRequestMetadata,
 					})
 					if err != nil {
-						if errors.Is(err, graph.ErrResolutionDepthExceeded) || errors.Is(err, graph.ErrCycleDetected) {
+						if errors.Is(err, graph.ErrResolutionDepthExceeded) {
 							resultsChan <- ListObjectsResult{Err: serverErrors.AuthorizationModelResolutionTooComplex}
 							return
 						}
@@ -350,7 +350,7 @@ func (q *ListObjectsQuery) evaluate(
 				}(res)
 
 			case err := <-errChan:
-				if errors.Is(err, graph.ErrResolutionDepthExceeded) || errors.Is(err, graph.ErrCycleDetected) {
+				if errors.Is(err, graph.ErrResolutionDepthExceeded) {
 					err = serverErrors.AuthorizationModelResolutionTooComplex
 				}
 

programming_language: Go
filename: server.go
change_type: ModificationType.MODIFY
diff: @@ -774,7 +774,7 @@ func (s *Server) Check(ctx context.Context, req *openfgav1.CheckRequest) (*openf
 	})
 	if err != nil {
 		telemetry.TraceError(span, err)
-		if errors.Is(err, graph.ErrResolutionDepthExceeded) || errors.Is(err, graph.ErrCycleDetected) {
+		if errors.Is(err, graph.ErrResolutionDepthExceeded) {
 			return nil, serverErrors.AuthorizationModelResolutionTooComplex
 		}
 

programming_language: Go
filename: server_test.go
change_type: ModificationType.MODIFY
diff: @@ -314,7 +314,7 @@ func TestAvoidDeadlockAcrossCheckRequests(t *testing.T) {
 
 	var wg sync.WaitGroup
 
-	wg.Add(2)
+	wg.Add(3)
 
 	var resp1 *openfgav1.CheckResponse
 	var err1 error
@@ -340,13 +340,30 @@ func TestAvoidDeadlockAcrossCheckRequests(t *testing.T) {
 		})
 	}()
 
+	var resp3 *openfgav1.CheckResponse
+	var err3 error
+	go func() {
+		defer wg.Done()
+
+		resp3, err3 = s.Check(context.Background(), &openfgav1.CheckRequest{
+			StoreId:              storeID,
+			AuthorizationModelId: modelID,
+			TupleKey:             tuple.NewCheckRequestTupleKey("document:1", "viewer", "user:andres"),
+		})
+	}()
+
 	wg.Wait()
 
 	require.NoError(t, err1)
+	require.NotNil(t, resp1)
 	require.False(t, resp1.GetAllowed())
 
 	require.NoError(t, err2)
-	require.False(t, resp2.GetAllowed())
+	require.NotNil(t, resp2)
+
+	require.NoError(t, err3)
+	require.NotNil(t, resp3)
+	require.True(t, resp3.GetAllowed())
 }
 
 func TestAvoidDeadlockWithinSingleCheckRequest(t *testing.T) {
@@ -405,7 +422,9 @@ func TestAvoidDeadlockWithinSingleCheckRequest(t *testing.T) {
 		AuthorizationModelId: modelID,
 		TupleKey:             tuple.NewCheckRequestTupleKey("document:1", "can_view", "user:jon"),
 	})
+
 	require.NoError(t, err)
+	require.NotNil(t, resp)
 	require.False(t, resp.GetAllowed())
 }
 

programming_language: Go