Skip to content

Internals

Read from the source at commit 4bb1d7b3. Every claim here should point at a file and line.

Code map

PathResponsibility
cmd/spicedb/main.goBinary entry point: builds the cobra command tree; serve starts gRPC/HTTP.
internal/services/v1/gRPC API v1: Permissions, Schema, Watch, Relationships.
internal/dispatch/Dispatch chain: caching/, singleflight/, remote/, combined/, graph/.
internal/graph/Graph traversal: check.go, expand.go, lookupresources*.go, lookupsubjects.go.
internal/datastore/, pkg/datastore/Storage abstraction and drivers (crdb, postgres, mysql, spanner, memdb, proxy).
pkg/schema/, pkg/schemadsl/Schema DSL (lexer, parser, compiler, generator) and schema model.
pkg/tuple/Relationship tuples and core data structures.
pkg/caveats/, internal/caveats/CEL-based Caveats.
pkg/zedtoken/ZedToken consistency-token encode/decode.

Core data structures

ObjectAndRelation (ONR) is the graph node. It is defined at pkg/tuple/structs.go:24 as three strings:

go
type ObjectAndRelation struct {
    ObjectID   string
    ObjectType string
    Relation   string
}

Its string form is type:id#relation (pkg/tuple/structs.go:59).

Relationship (pkg/tuple/structs.go:80) is SpiceDB's relation tuple. It embeds a RelationshipReference (resource ONR plus subject ONR, structs.go:70) and carries OptionalCaveat, OptionalExpiration, and OptionalIntegrity. ToCoreTuple (structs.go:89) converts it to the protobuf form passed through the engine.

Between dispatch hops the request travels in a ResolverMeta (protobuf, populated at internal/graph/computed/computecheck.go:128) carrying AtRevision, DepthRemaining, TraversalBloom, and SchemaHash.

The storage contract is the datastore.Datastore interface in pkg/datastore/datastore.go: SnapshotReader(Revision) (:707), OptimizedRevision (:711), HeadRevision (:715), Watch(...) (:739), and ReadWriteTx(...) (:768). It is an MVCC-style model where every read is pinned to a revision. ZedToken (pkg/zedtoken/zedtoken.go, NewFromRevision at :71) encodes a datastore revision plus schema hash into an opaque token clients hand back to demand a minimum freshness.

A path worth tracing

The interesting branch is loop protection in dispatch. SpiceDB schemas can express self-referential or cyclic relations (a folder whose view permission walks parent->view), so the dispatcher must traverse a graph that could recurse forever.

The check entry builds a traversal bloom filter sized to the maximum depth at internal/graph/computed/computecheck.go:113:

go
bf, err := v1.NewTraversalBloomFilter(uint(params.MaximumDepth))

The filter rides along in every dispatched request's metadata (computecheck.go:131, TraversalBloom: bf). The singleflight dispatcher records each dispatch key into that filter and checks whether it was seen before, at internal/dispatch/singleflight/singleflight.go:74:

go
possiblyLoop, err := req.Metadata.RecordTraversal(keyString)
if err != nil {
    return &v1.DispatchCheckResponse{Metadata: &v1.ResponseMeta{DispatchCount: 1}}, err
} else if possiblyLoop {
    log.Debug().Object("DispatchCheckRequest", req).Str("key", keyString).Msg("potential DispatchCheckRequest loop detected")
    singleFlightCount.WithLabelValues("DispatchCheck", "loop").Inc()
    return d.delegate.DispatchCheck(ctx, req)
}

When a key looks like a repeat, singleflight does not coalesce it; it passes it straight to the delegate. The comment at singleflight.go:70-73 explains why: coalescing a recursive call under singleflight would deadlock, because the in-flight parent is itself waiting on the child. A bloom-filter false positive only costs one extra dispatch, which is the cheaper side of the trade-off.

Things that surprised me

The traversal bloom filter is hand-written, not generated. pkg/proto/dispatch/v1/02_resolvermeta.go opens with // This file is *not* autogenerated. (:1) and RecordTraversal is implemented there at :15: it unmarshals the bloom from the request, runs TestString on the key, returns possiblyLoop=true if present, otherwise AddString and re-marshals. The default false-positive rate is 0.001, that is 0.1% (02_resolvermeta.go:41).

There is also a defensive fallback in singleflight: if a request arrives with an empty TraversalBloom (an older caller that predates the feature), the dispatcher creates a fresh filter of size 50 and skips singleflight entirely for that request rather than risk an un-bounded recursion (internal/dispatch/singleflight/singleflight.go:58-68). Loop safety lives in the dispatch layer, not in the schema or the graph walker.