Thesis
FactSource should become the bottom of the ReBAC layer rather than landing alongside RelationshipResolver.
A relationship check is just a typed fact load:
RelationshipQuery<SubjectId, ResourceId, Relation> -> bool
Keeping both RelationshipResolver::{has_relationship, has_relationship_batch} and FactSource::load_many would split batching, caching, max-batch hints, error handling, and tracing across two abstractions that are doing the same job.
Proposed shape
Fact keys and sources
pub trait FactKey: Eq + Hash + Clone + Send + Sync + 'static {
type Value: Clone + Send + Sync + 'static;
const NAME: &'static str; // tracing/diagnostics only, not the registry key
}
pub enum FactLoadError {
SourceNotRegistered { fact_name: &'static str },
SourceContractViolation { fact_name: &'static str, expected: usize, actual: usize },
LoaderCancelled { fact_name: &'static str },
Backend(Arc<dyn std::error::Error + Send + Sync>),
}
pub enum FactLoadResult<V> {
Found(V),
Missing,
Error(FactLoadError),
}
#[async_trait]
pub trait FactSource<K: FactKey>: Send + Sync {
async fn load_many(&self, keys: &[K]) -> Vec<FactLoadResult<K::Value>>;
fn max_batch_size(&self) -> Option<NonZeroUsize> { None }
}
FactSource::load_many receives keys that are unique within the call. It must return exactly one result per input key, in input order. The session deduplicates before calling the source, chunks by FactSource::max_batch_size, caches results/errors for the request, and expands back to caller-visible order including duplicates.
Session
pub struct EvaluationSession { /* type-indexed sources, caches, in-flight loads */ }
The session is per request/authorization pass and is passed by shared reference. It owns request-scoped caches and a type-indexed source registry keyed by TypeId, not by FactKey::NAME.
A missing source returns FactLoadResult::Error(FactLoadError::SourceNotRegistered { .. }); policies map that to denied. Cached errors live only for the session lifetime and do not survive into later requests.
Concurrent get(K)/get_many([K]) calls for an in-flight key should join the existing load rather than firing a second backend call. If the loader task is cancelled or panics before it completes, a drop guard must remove the in-flight entries, cache Error(LoaderCancelled), and wake waiters. This keeps the DataLoader property even before policy evaluation itself becomes concurrent.
Register sources during session setup. Replacing a source while keys for the same type are in flight is unsupported and should be documented/debug-asserted.
Policy API
Make the v0.3 break clean: one single-item entry point and one batch entry point.
#[async_trait]
pub trait Policy<S, R, A, C>: Send + Sync {
async fn evaluate(&self, ctx: &EvalCtx<'_, S, R, A, C>) -> PolicyEvalResult;
async fn evaluate_batch<'i>(&self, ctx: &BatchEvalCtx<'i, S, R, A, C>)
-> Vec<PolicyEvalResult>
{
/* default point-loop */
}
fn policy_type(&self) -> &str;
}
pub struct EvalCtx<'a, S, R, A, C> {
pub subject: &'a S,
pub action: &'a A,
pub resource: &'a R,
pub context: &'a C,
pub session: &'a EvaluationSession,
}
pub struct BatchEvalCtx<'i, S, R, A, C> {
pub subject: &'i S,
pub action: &'i A,
pub items: &'i [PolicyBatchItem<'i, R, C>],
pub session: &'i EvaluationSession,
}
All checker evaluation methods should require an explicit session. RBAC/ABAC-only callers use EvaluationSession::empty(). This avoids the mixed-stack footgun where a convenience method silently creates an empty source registry and denies every ReBAC check.
PermissionChecker::with_max_batch_size remains a defensive cap on the number of pending items sent to each policy batch call. FactSource::max_batch_size is the source-level cap after session deduplication.
ReBAC
#[derive(Hash, Eq, PartialEq, Clone, Debug)]
pub struct RelationshipQuery<SubjectId, ResourceId, Relation> {
pub subject_id: SubjectId,
pub resource_id: ResourceId,
pub relation: Relation,
}
pub struct RebacPolicy<S, R, A, C, SubjectId, ResourceId, Relation> {
subject_id: Arc<dyn Fn(&S) -> SubjectId + Send + Sync>,
resource_id: Arc<dyn Fn(&R) -> ResourceId + Send + Sync>,
relation: Relation,
}
RebacPolicy extracts flat IDs synchronously, builds RelationshipQuery, and loads through EvaluationSession. Async extraction is intentionally unsupported; if extracting the ID needs a backend call, model that as another FactSource.
Decision mapping:
Found(true) -> granted
Found(false) -> denied, no matching relationship
Missing -> denied, relationship fact missing
Error(SourceNotRegistered | SourceContractViolation | LoaderCancelled | Backend(_)) -> denied, fact load failed
RBAC stays in memory in v0.3. Moving role lookup onto FactSource<RoleFactKey> can be a mechanical follow-up if a real backend-backed role source appears.
LookupSource carve-out
Do not make unbounded Vec<RId> the canonical graph lookup shape. Reserve a paginated/streaming lookup surface, but do not require implementations in v0.3.
pub struct LookupPage<ResourceId, Cursor> {
pub resources: Vec<ResourceId>,
pub next_cursor: Option<Cursor>,
}
#[async_trait]
pub trait LookupSource<SId, RId, Re>: FactSource<RelationshipQuery<SId, RId, Re>> {
type Cursor: Clone + Send + Sync + 'static;
async fn lookup_resources(
&self,
subject: &SId,
relation: &Re,
cursor: Option<Self::Cursor>,
limit: Option<NonZeroUsize>,
) -> Result<LookupPage<RId, Self::Cursor>, LookupError>;
}
Tracing
A batch fact load should appear once in tracing with a session-scoped load id and key count. Per-item policy decisions should remain separate so an audit can connect one shared load to many decisions without logging N duplicate backend loads.
Removed from the v0.2 shape
RelationshipResolver
RelationshipResolver::has_relationship
RelationshipResolver::has_relationship_batch
- ReBAC resolver fields that take
&R
- checker evaluation methods that create an implicit empty session
Policy::evaluate_access / Policy::evaluate_access_batch
policy_type(&self) -> String
Migration from 0.2
- Custom
Policy::evaluate_access(...) impls become Policy::evaluate(&self, ctx: &EvalCtx<...>).
- Custom batch overrides become
Policy::evaluate_batch(&self, ctx: &BatchEvalCtx<...>).
- Callers create a request session and pass it to checker evaluation. RBAC/ABAC-only code can use
EvaluationSession::empty().
- Custom
RelationshipResolver impls become FactSource<RelationshipQuery<SubjectId, ResourceId, Relation>> impls.
- ReBAC construction changes from resolver-plus-relation to subject/resource ID extractors plus relation.
Acceptance criteria
FactSource::load_many is documented and tested as unique-key input, exact-length output, input-order output.
EvaluationSession::get_many preserves caller order and duplicates while deduplicating source calls.
- Concurrent requests for the same in-flight key join one source load.
- Cancellation or panic of the leader load task wakes waiters and returns
Error(LoaderCancelled) instead of hanging.
- Missing source, backend error, missing fact, and wrong source result count all fail closed with distinct reasons.
- Session chunking by
FactSource::max_batch_size preserves global ordering and duplicate expansion.
RebacPolicy no longer depends on RelationshipResolver or &R-based resolver APIs.
- Checker evaluation requires an explicit
EvaluationSession.
policy_type returns &str.
LookupSource is paginated/streaming-shaped and optional.
- The PostgreSQL 18 bulk ReBAC example implements
FactSource<RelationshipQuery<Uuid, Uuid, String>>, fail-closes backend errors, and shows point-query vs bulk-query behavior.
- Contract tests include session unit tests, boxed
dyn Policy batch dispatch, OR pending-set short-circuiting, and proptest coverage for ordering/dedup/chunking.
Thesis
FactSourceshould become the bottom of the ReBAC layer rather than landing alongsideRelationshipResolver.A relationship check is just a typed fact load:
Keeping both
RelationshipResolver::{has_relationship, has_relationship_batch}andFactSource::load_manywould split batching, caching, max-batch hints, error handling, and tracing across two abstractions that are doing the same job.Proposed shape
Fact keys and sources
FactSource::load_manyreceives keys that are unique within the call. It must return exactly one result per input key, in input order. The session deduplicates before calling the source, chunks byFactSource::max_batch_size, caches results/errors for the request, and expands back to caller-visible order including duplicates.Session
The session is per request/authorization pass and is passed by shared reference. It owns request-scoped caches and a type-indexed source registry keyed by
TypeId, not byFactKey::NAME.A missing source returns
FactLoadResult::Error(FactLoadError::SourceNotRegistered { .. }); policies map that to denied. Cached errors live only for the session lifetime and do not survive into later requests.Concurrent
get(K)/get_many([K])calls for an in-flight key should join the existing load rather than firing a second backend call. If the loader task is cancelled or panics before it completes, a drop guard must remove the in-flight entries, cacheError(LoaderCancelled), and wake waiters. This keeps the DataLoader property even before policy evaluation itself becomes concurrent.Register sources during session setup. Replacing a source while keys for the same type are in flight is unsupported and should be documented/debug-asserted.
Policy API
Make the v0.3 break clean: one single-item entry point and one batch entry point.
All checker evaluation methods should require an explicit session. RBAC/ABAC-only callers use
EvaluationSession::empty(). This avoids the mixed-stack footgun where a convenience method silently creates an empty source registry and denies every ReBAC check.PermissionChecker::with_max_batch_sizeremains a defensive cap on the number of pending items sent to each policy batch call.FactSource::max_batch_sizeis the source-level cap after session deduplication.ReBAC
RebacPolicyextracts flat IDs synchronously, buildsRelationshipQuery, and loads throughEvaluationSession. Async extraction is intentionally unsupported; if extracting the ID needs a backend call, model that as anotherFactSource.Decision mapping:
Found(true)-> grantedFound(false)-> denied, no matching relationshipMissing-> denied, relationship fact missingError(SourceNotRegistered | SourceContractViolation | LoaderCancelled | Backend(_))-> denied, fact load failedRBAC stays in memory in v0.3. Moving role lookup onto
FactSource<RoleFactKey>can be a mechanical follow-up if a real backend-backed role source appears.LookupSource carve-out
Do not make unbounded
Vec<RId>the canonical graph lookup shape. Reserve a paginated/streaming lookup surface, but do not require implementations in v0.3.Tracing
A batch fact load should appear once in tracing with a session-scoped load id and key count. Per-item policy decisions should remain separate so an audit can connect one shared load to many decisions without logging N duplicate backend loads.
Removed from the v0.2 shape
RelationshipResolverRelationshipResolver::has_relationshipRelationshipResolver::has_relationship_batch&RPolicy::evaluate_access/Policy::evaluate_access_batchpolicy_type(&self) -> StringMigration from 0.2
Policy::evaluate_access(...)impls becomePolicy::evaluate(&self, ctx: &EvalCtx<...>).Policy::evaluate_batch(&self, ctx: &BatchEvalCtx<...>).EvaluationSession::empty().RelationshipResolverimpls becomeFactSource<RelationshipQuery<SubjectId, ResourceId, Relation>>impls.Acceptance criteria
FactSource::load_manyis documented and tested as unique-key input, exact-length output, input-order output.EvaluationSession::get_manypreserves caller order and duplicates while deduplicating source calls.Error(LoaderCancelled)instead of hanging.FactSource::max_batch_sizepreserves global ordering and duplicate expansion.RebacPolicyno longer depends onRelationshipResolveror&R-based resolver APIs.EvaluationSession.policy_typereturns&str.LookupSourceis paginated/streaming-shaped and optional.FactSource<RelationshipQuery<Uuid, Uuid, String>>, fail-closes backend errors, and shows point-query vs bulk-query behavior.dyn Policybatch dispatch, OR pending-set short-circuiting, and proptest coverage for ordering/dedup/chunking.