Internals
Read from the source at commit
9a556d8. Every claim here points at a file and line.
Code map
| Path | Responsibility |
|---|---|
cmd/ | CLI: run, migrate, validate-models, version |
pkg/server/ | gRPC/HTTP handlers, one per API surface |
pkg/server/commands/ | Transport-independent business logic |
internal/graph/ | Check resolution engine and resolver chain |
internal/planner/ | Thompson Sampling strategy planner |
pkg/typesystem/ | Model parsing/validation and weighted graph |
pkg/storage/ | Datastore interfaces and implementations |
pkg/tuple/ | Relationship tuple helpers |
Core data structures
Tuple / TupleKey is the basic unit of relationship data: an (object, relation, user) triple such as document:1 # viewer @ user:alice. The type aliases the protobuf TupleKey (pkg/tuple/tuple.go:15):
type Tuple openfgav1.TupleKey
ResolveCheckRequest / ResolveCheckResponse represent one node in the tree of subproblems a check decomposes into. The request carries the visited-paths set used for cycle detection and resolution metadata such as dispatch and datastore-query counts (internal/graph/resolve_check_request.go, resolve_check_response.go).
Userset rewrites (protobuf) are how a relation's definition is expressed as a set-algebra tree: Userset_This, ComputedUserset, TupleToUserset, Union, Intersection, Difference. CheckRewrite evaluates this tree (internal/graph/check.go:1046-1063).
TypeSystem holds the validated model and a WeightedAuthorizationModelGraph built from it (pkg/typesystem/typesystem.go:184, built at typesystem.go:242). The weighted graph is what makes the PathExists pruning possible.
ThompsonStats / PlanConfig model the engine's belief about each resolution strategy's latency as a Normal-gamma distribution (internal/planner/thompson.go:13-23, internal/planner/config.go:5).
A path worth tracing
Follow (*LocalChecker).ResolveCheck (internal/graph/check.go:395). It is, by its own contract, a recursive function that resolves one node out of a tree of problems (internal/graph/interface.go:13-31).
It guards depth first, then detects cycles and returns a non-error negative answer when one is found (check.go:415-427):
if req.GetRequestMetadata().Depth == c.maxResolutionDepth {
return nil, ErrResolutionDepthExceeded
}
cycle := c.hasCycle(req)
if cycle {
span.SetAttributes(attribute.Bool("cycle_detected", true))
return &ResolveCheckResponse{
Allowed: false,
A self-defining tuple is allowed immediately (check.go:434). Then comes the pruning step: if the weighted model graph says there is no path from the user to the relation on this object type, the check returns false without touching the datastore (check.go:455-463):
hasPath, err := typesys.PathExists(tupleKey.GetUser(), relation, objectType)
if err != nil {
return nil, err
}
if !hasPath {
return &ResolveCheckResponse{
Allowed: false,
}, nil
}
Otherwise it evaluates the relation's rewrite rule (check.go:465). CheckRewrite switches on the rewrite kind (check.go:1046-1063): Userset_This to checkDirect, ComputedUserset to checkComputedUserset, TupleToUserset to checkTTU, and the set operators to checkSetOperation.
Set operations run concurrently. union runs every handler in a worker pool and short-circuits the moment one returns allowed, cancelling the rest (internal/graph/check.go:160, short-circuit at check.go:206-209):
if outcome.resp.Allowed {
// Short-circuit success. defer cancel() will clean up workers.
return outcome.resp, nil
}
intersection (check.go:222) stops at the first negative; exclusion (check.go:295) is true when the base handler is true and the subtract handler is false.
Things that surprised me
The resolver chain loops back on itself. Build orders resolvers least-to-most expensive, then sets the last resolver's delegate to the first (internal/graph/builder.go:96-104):
for i, resolver := range c.resolvers {
if i == len(c.resolvers)-1 {
resolver.SetDelegate(c.resolvers[0])
continue
}
resolver.SetDelegate(c.resolvers[i+1])
}
So when LocalChecker recurses into a subproblem, the recursion re-enters at the cache resolver rather than calling itself directly, letting every subproblem benefit from caching and throttling. The contract that this must not cause infinite recursion is spelled out on the interface (interface.go:38-40).
Strategy selection is online learning, not a heuristic. (*keyPlan).Select implements the Thompson Sampling decision rule: draw a latency sample from each strategy's learned distribution and pick the minimum (internal/planner/plan.go:46-69):
for k, plan := range resolvers {
ts := kp.getOrCreateStats(plan)
sampledTime := ts.Sample(rng)
if bestResolver == "" || sampledTime < minSampledTime {
minSampledTime = sampledTime
bestResolver = k
}
}
Measured latencies feed back via UpdateStats, so each query path converges toward its lowest-latency strategy while still exploring. The intent (the meaning of InitialGuess, Lambda, Alpha, Beta, and the exploration/exploitation balance) is documented at length in the config comments (internal/planner/config.go:5-43).
There is a second Check implementation hiding behind a flag. The v2 weighted-graph Check is tried first when ExperimentalWeightedGraphCheck is on, and silently falls back to v1 for any model the weighted graph cannot represent (pkg/server/check.go:69-152). A naive reading of the v1 path alone misses that production traffic may be taking an entirely different route.