ADR-012: Materialized Group Closure for Permission Resolution¶
Date: 2026-04-14 Status: Accepted
Context¶
The party archetype (ADR-011) introduces hierarchical group membership: a user can be a member of a group, which can itself be a member of another group. Permission resolution must traverse this hierarchy to determine a user's effective project roles — a user inherits the project roles of all their ancestor groups.
The straightforward implementation uses a recursive CTE at auth time:
WITH RECURSIVE ancestors AS (
SELECT to_party_id FROM party_relationships
WHERE from_party_id = $userPartyID AND relationship_name = 'group_member'
UNION
SELECT pr.to_party_id FROM party_relationships pr
JOIN ancestors a ON pr.from_party_id = a.to_party_id
WHERE pr.relationship_name = 'group_member'
)
SELECT from_role FROM party_relationships
WHERE relationship_name = 'project_member'
AND from_party_id IN (SELECT * FROM ancestors)
AND to_party_id = $projectID
This runs on every authenticated API request. At low scale (< 500 parties, depth < 5) it is fast. Under load or with deep hierarchies it degrades: O(depth × edges) per request, with SQLite's query planner adding contention under concurrent reads.
Decision¶
Pre-compute the transitive closure of all containment relationships into a
party_group_closure(member_party_id, ancestor_party_id) table. Rebuild the closure
synchronously within the same database transaction whenever a containment relationship
(any relationship_name in ContainmentRelationships) is added or removed.
Permission resolution steps 2 and 3 (from the spec) become indexed point lookups:
-- Step 2: group global roles
SELECT r.permissions FROM global_party_roles gpr
JOIN roles r ON r.id = gpr.role_id
WHERE gpr.party_id IN (
SELECT ancestor_party_id FROM party_group_closures
WHERE member_party_id = $userPartyID
)
-- Step 3: project-scoped roles
SELECT from_role FROM party_relationships
WHERE relationship_name = 'project_member'
AND to_party_id = $projectPartyID
AND from_party_id IN (
$userPartyID,
SELECT ancestor_party_id FROM party_group_closures WHERE member_party_id = $userPartyID
)
No recursive CTE executes at auth time. All three steps use indexed columns.
Per-request context cache: RequireProjectPermission middleware caches the ancestor
set from party_group_closures in r.Context() on the first resolution per request.
Subsequent permission checks within the same request reuse the cached set — one DB
round-trip per request regardless of how many middleware checks fire.
Rebuild scope: The closure rebuild is a full-table rebuild — all rows in
party_group_closures are deleted and re-inserted from a single recursive CTE on every
containment relationship change. This is correct regardless of edge insertion order.
A scoped rebuild (only rows for the affected group) is insufficient: adding edge A→B must also update the closure for every ancestor of B, since A's transitive descendants become reachable from those ancestors. The full-table rebuild avoids this bookkeeping and remains under 10ms at AgentLens scale (< 10k parties).
Consequences¶
Positive¶
- Auth check complexity drops from O(depth × edges) recursive CTE to O(1) indexed lookups
- Works efficiently on both SQLite and Postgres without query planner differences
- Rebuild is transactional — closure is never partially updated; no stale reads
- Per-request cache eliminates repeated DB calls within a single request
Negative / Trade-offs¶
- Write operations that change group membership pay the cost of a full-table closure rebuild (synchronous, same transaction). At AgentLens scale this is under 10ms. Acceptable since membership changes are infrequent compared to reads
party_group_closuresis a derived table; it must never be edited directly- Circular group membership (A member of B, B member of A) is rejected by the store layer before the relationship is inserted (cycle detection via a closure lookup)
Neutral¶
- Closure rebuild uses a recursive CTE internally, but only on membership write, not on every auth read — the expensive operation is amortized
- Adding a new containment relationship kind requires adding it to
model.ContainmentRelationships(internal/model/party.go) to trigger rebuilds; forgetting to do so causes stale closure data (silent bug) - The full-table rebuild is simpler to reason about than scoped rebuilds and eliminates the class of correctness bugs caused by out-of-order edge insertion
Alternatives considered¶
| Option | Why rejected |
|---|---|
| On-demand recursive CTE at auth time | O(depth × edges) per request; degrades under load; SQLite contention |
| JWT-embedded project roles | Roles go stale until re-login; unacceptable when membership changes must take immediate effect |
| Depth limit + bounded BFS | Still O(depth × edges); adds artificial constraint on group hierarchy |
| Async closure rebuild (eventual consistency) | Auth checks could use stale data mid-rebuild; consistency complexity not worth it at current scale |