Capability & Membership Inheritance
This chapter builds on the scope tree from Concepts & Scopes and the membership model from Governance. It explains a single idea in depth: membership is not always written down. A member of a parent group can be a member of a child subgroup by inheritance, with no operation recorded for that child — and the protocol resolves it by walking the tree at query time.
The scope tree narrows as you descend
Section titled “The scope tree narrows as you descend”Governance scopes form a tree: a namespace at the root, subgroups nested beneath it, each its own encryption and convergence domain. The defining property is that membership only ever narrows as you descend. Whoever can act inside a child is a subset of whoever can act in its parent chain — never a superset.
This is what makes “pointing an operation up the tree” safe: an inner scope can name an outer one without leaking, because the outer scope’s audience is always at least as large.
Two things flow down the tree, and they are deliberately separate:
- Membership — being part of the audience that can read and write a scope.
- Capabilities — the specific fine-grained rights a member holds within a group.
The first can be inherited from an ancestor. The second is resolved per group, with a default fallback. The rest of this chapter takes each in turn.
Open vs Restricted: the visibility wall
Section titled “Open vs Restricted: the visibility wall”Every subgroup carries one bit of visibility state, VisibilityMode:
| Mode | Meaning |
|---|---|
Open | Parent-group members are inherited as members of this subgroup — and transitively of any contexts it contains — provided they hold the join capability at the anchor parent. No explicit operation per inheritor. |
Restricted | A wall. Membership requires a direct, deliberate admission. The subgroup’s existence and membership are hidden from non-members. |
An absent visibility setting is read as Restricted — the safer default. The store helper collapses a missing key to Restricted, and a test pins this: an unwritten subgroup is closed, not open.
pub fn subgroup_visibility(&self, group_id: &ContextGroupId) -> EyreResult<VisibilityMode> { let handle = self.store.handle(); let key = GroupSubgroupVis::new(group_id.to_bytes()); let value = handle.get(&key)?; Ok(match value.map(|v| v.mode) { Some(0) => VisibilityMode::Open, _ => VisibilityMode::Restricted, })}The crucial structural fact: a Restricted subgroup is a wall regardless of what sits above it. Inheritance climbing up from a deeper scope stops the moment it hits a Restricted boundary — it never sees the members on the far side. A Restricted subgroup nested inside an Open chain still blocks; an Open subgroup nested inside a Restricted one inherits nothing through the wall.
How the membership-path walk actually runs
Section titled “How the membership-path walk actually runs”Resolving “is this identity a member of this subgroup?” is a parent-chain walk. The live implementation is MembershipRepository::check_path; it returns a MembershipPath describing how the decision was reached:
pub enum MembershipPath { /// Not a member, directly or by inheritance. None, /// Has a direct membership row in the subgroup. Direct, /// Inherits from the closest ancestor where they hold a direct row (`anchor`). /// `via_admin` is true for an admin grant; false for CAN_JOIN_OPEN_SUBGROUPS. Inherited { anchor: ContextGroupId, via_admin: bool },}The walk proceeds in this order:
-
Direct row first. If the identity has a membership row written directly in the target subgroup, return
Directimmediately. No walking. -
Climb while Open. Otherwise, starting at the target group, check the subgroup’s visibility. If it is not
Open, stop — the identity is not a member (subject to step 4’s recorded decision). This is the wall. -
Step to the parent. If there is no parent, stop. Otherwise look at the parent:
- If the identity is an admin of that parent, return
Inherited { via_admin: true }right away — admin authority flows down an Open chain unconditionally. - If the identity holds a direct member row in that parent, record a tentative decision (the first such ancestor wins): they are an inherited member iff they hold
CAN_JOIN_OPEN_SUBGROUPSat that anchor; otherwise the recorded decision isNone.
- If the identity is an admin of that parent, return
-
Continue or return. Keep climbing. When the chain ends (a non-
Openboundary or no parent), return the recorded decision if one was made, elseNone.
Here is the load-bearing part of the loop, verbatim:
let mut anchor_decision: Option<MembershipPath> = None;let mut current = *group_id;for _ in 0..=MAX_NAMESPACE_DEPTH { if CapabilitiesRepository::new(self.store).subgroup_visibility(¤t)? != VisibilityMode::Open { return Ok(anchor_decision.unwrap_or(MembershipPath::None)); } let Some(parent) = NamespaceRepository::new(self.store).parent(¤t)? else { return Ok(anchor_decision.unwrap_or(MembershipPath::None)); }; if self.is_admin(&parent, identity)? { return Ok(MembershipPath::Inherited { anchor: parent, via_admin: true }); } if has_direct_member(self.store, &parent, identity)? && anchor_decision.is_none() { let caps = CapabilitiesRepository::new(self.store) .member_capability(&parent, identity)? .unwrap_or(0); anchor_decision = Some(if caps & MemberCapabilities::CAN_JOIN_OPEN_SUBGROUPS != 0 { MembershipPath::Inherited { anchor: parent, via_admin: false } } else { MembershipPath::None }); } current = parent;}Two subtleties worth calling out:
- The capability is checked at the anchor, not the target. Whether you inherit into a deep Open subgroup depends on the
CAN_JOIN_OPEN_SUBGROUPSbit you hold in the ancestor where your direct row lives — not on anything written at the subgroup you are being tested against. - An admin grant short-circuits; a non-admin member is tentative. An admin of any Open ancestor is a member of everything below on the chain. A non-admin member’s inheritance is decided by the first direct-row ancestor encountered and may resolve to
None(the bit is missing) even though they are a member higher up — that is the per-member deny-list use of the bit, described below.
The loop is bounded by MAX_NAMESPACE_DEPTH (16); exceeding it is treated as a corrupt store (a possible cycle) and bails rather than looping forever.
The same walk, off the projection
Section titled “The same walk, off the projection”The live walk above reads the store. Sync and delta-authorization need the same answer resolved from the unified projection at a causal cut, so AclView::is_member_at_cut ports the walk faithfully over the folded view — no store reads. It mirrors check_path edge for edge: direct/admin short-circuit, then climb while !edge.restricted, recording the first direct-row ancestor’s CAN_JOIN_OPEN_SUBGROUPS decision.
let mut anchor_is_member: Option<bool> = None;let mut current = group;for _ in 0..=MAX_NAMESPACE_DEPTH { let Some(edge) = self.subgroups.get(&ScopeId::from(current.to_bytes())) else { return anchor_is_member.unwrap_or(false); }; if edge.restricted { return anchor_is_member.unwrap_or(false); } let parent = ContextGroupId::from(*edge.parent.as_bytes()); if is_admin(parent) { return true; } if anchor_is_member.is_none() && self.groups.get(&parent).is_some_and(|m| m.contains_key(author)) { anchor_is_member = Some(effective_cap(&parent) & CAN_JOIN_OPEN_SUBGROUPS != 0); } current = parent;}In the projection, the subgroup tree is materialized as edges child → (parent, restricted), one SubgroupEdge per live subgroup (latest exists is true). The restricted flag is exactly the visibility wall: its doc comment names it “a visibility wall that blocks inheritance through it.”
The capability bitmask
Section titled “The capability bitmask”A member’s capabilities are a u32 bitfield, MemberCapabilities. The bits relevant to the tree and to inheritance:
| Bit | Constant | Value | What it permits |
|---|---|---|---|
| 0 | CAN_CREATE_CONTEXT | 1 << 0 | Create a context within the group. |
| 1 | CAN_INVITE_MEMBERS | 1 << 1 | Invite new members. |
| 2 | CAN_JOIN_OPEN_SUBGROUPS | 1 << 2 | Be inherited as a member of any Open subgroup beneath this group (and its contexts). The inheritance gate. |
| 3 | MANAGE_MEMBERS | 1 << 3 | Manage the group’s members. |
| 4 | MANAGE_APPLICATION | 1 << 4 | Manage the group’s application. |
| 5 | CAN_CREATE_SUBGROUP | 1 << 5 | Non-admin creation of a subgroup directly under the namespace root only. |
| 6 | CAN_DELETE_SUBGROUP | 1 << 6 | Non-admin deletion of a subgroup and its subtree (checked at the root). |
| 7 | CAN_MANAGE_VISIBILITY | 1 << 7 | Flip a subgroup’s Open ↔ Restricted without full admin. |
| 8 | CAN_MANAGE_METADATA | 1 << 8 | Set group/member/context metadata without full admin. |
CAN_JOIN_OPEN_SUBGROUPS is the bit that drives inheritance. Its design is a deny-list, not an allow-list: it is granted by default to non-admin members, and an admin revokes it per member when they want a specific user kept out of Open subgroups even though that user stays in the parent. So the common case is “everyone inherits”; clearing the bit is the deliberate exception.
CAN_CREATE_SUBGROUP and CAN_DELETE_SUBGROUP are checked at the namespace root on purpose: the apply-side authorization runs on every peer, and a peer can only verify the creator’s bit if it can read the parent group’s member-capability rows. Every namespace member holds the root group’s key, but not necessarily a deeper subgroup’s — so non-admin create/delete is scoped to root-level subgroups, while admins act at any depth.
Default vs per-member capability resolution
Section titled “Default vs per-member capability resolution”Capabilities resolve at two levels per group:
- Default capabilities (
DefaultCapabilitiesSet→GroupDefaultCaps) — a singleu32for the group, applied to new members as they join. - Per-member capabilities (
MemberCapabilitySet→GroupMemberCapability) — an explicitu32override for one identity in one group.
The resolution rule is override-or-default: a member’s effective bitmask is their explicit per-member value if one exists, otherwise the group default, otherwise zero. The live read is member_capability(group, member).unwrap_or(default); the projection mirrors it with a default_cap_base fallback when nothing explicit is folded.
When a non-admin member is added, the group’s default capabilities are stamped onto their per-member row at join time (admins are skipped — they bypass the bitmask anyway):
if !is_admin { let capabilities = CapabilitiesRepository::new(self.store); if let Some(defaults) = capabilities.default_capabilities(group_id)? { if defaults != 0 { capabilities.set_member_capability(group_id, identity, defaults)?; } }}Effective capabilities of an inherited member
Section titled “Effective capabilities of an inherited member”What capabilities does an inherited member hold in the subgroup they inherited into? The answer is read from the target subgroup’s capability rows, with one asymmetry for the deny-list: an inherited member who is on the subgroup’s deny-list resolves to no capabilities at all.
pub fn effective_capabilities(&self, group_id, identity) -> EyreResult<Option<u32>> { match self.check_path(group_id, identity)? { MembershipPath::None => Ok(None), MembershipPath::Inherited { .. } if DenyListRepository::new(self.store).is_denied(group_id, identity)? => { Ok(None) } MembershipPath::Direct | MembershipPath::Inherited { .. } => Ok(Some( CapabilitiesRepository::new(self.store) .member_capability(group_id, identity)? .unwrap_or(0), )), }}A direct member is never short-circuited by the deny-list here; only inherited membership is. An inherited member who has no explicit capability row in the target resolves to 0 — they are present in the audience, but hold no delegated rights at that subgroup unless something was written for them there.
Visibility of subgroups in listings
Section titled “Visibility of subgroups in listings”The wall also governs what shows up when a caller lists subgroups. subgroup_visible_to decides:
- An
Opensubgroup is visible to everyone. - A
Restrictedsubgroup is visible only to a caller who is an inherited admin of the parent, or who is a member of the subgroup itself. To everyone else it is hidden — its existence is part of what the wall conceals.
pub fn subgroup_visible_to(&self, parent_group_id, child_group_id, caller) -> EyreResult<bool> { if CapabilitiesRepository::new(self.store).subgroup_visibility(child_group_id)? == VisibilityMode::Open { return Ok(true); } let Some(caller_pk) = caller else { return Ok(false) }; if self.is_inherited_admin(parent_group_id, caller_pk)? { return Ok(true); } self.is_member(child_group_id, caller_pk)}Worked example: Open auto-join vs Restricted admission
Section titled “Worked example: Open auto-join vs Restricted admission”Take a namespace acme with three members and two subgroups:
- Members of
acme: Alice (admin), Bob (ordinary member, holds the defaultCAN_JOIN_OPEN_SUBGROUPS), Mallory (ordinary member whoseCAN_JOIN_OPEN_SUBGROUPSan admin has revoked — the deny-list case). - Subgroups:
engisOpen;payrollisRestricted.
No membership op is ever written on either subgroup for these three. Resolving each identity against each subgroup runs the parent-chain walk above:
| Identity | eng (Open) | payroll (Restricted) |
|---|---|---|
Alice (admin of acme) | Inherited { anchor: acme, via_admin: true } — admin flows down the Open chain | not an inherited member; the wall returns before any parent is consulted. She can see and admit (inherited admin of the parent), but is not herself a member until admitted |
| Bob (holds the bit) | Inherited { anchor: acme, via_admin: false } — auto-joined, no op | None — the wall stops the walk at the target; the subgroup is hidden from him entirely |
| Mallory (bit revoked) | None — the anchor row exists but the bit is clear, so the recorded decision denies | None — same wall |
Open eng: auto-join, no op. Bob and Alice replicate eng’s contexts without any operation naming them in eng. Auto-follow walks up to the anchor — their direct row in acme — and honours that row’s auto_follow.contexts flag. Revoking Bob’s acme membership makes his eng inheritance simply stop resolving on the next walk; there is no eng row to clean up.
Restricted payroll: explicit admission. For anyone to join payroll, an admin must write a direct MemberAdded row in payroll itself and deliver its scope key. Until then the subgroup’s very existence is concealed from non-members: subgroup_visible_to returns true only for a member of payroll or an inherited admin of its parent. Flipping payroll to Open later would open inheritance to every parent member who holds the bit — which is why the flip is gated behind admin or CAN_MANAGE_VISIBILITY.
Summary
Section titled “Summary”- Membership narrows as you descend the scope tree; it is never widened by a child.
- An
Opensubgroup inherits parent members who holdCAN_JOIN_OPEN_SUBGROUPSat their anchor — no operation is written for the inheritance; it is resolved at query time by walking the open-ancestor chain. - A
Restrictedsubgroup is a wall: membership needs a direct admission, and the subgroup is hidden from non-members. - The walk stops at the first non-
Openboundary; the livecheck_pathand the projection’sis_member_at_cutimplement the identical walk so live and at-cut answers agree. - Capabilities resolve per group as override-or-default;
CAN_JOIN_OPEN_SUBGROUPSis a default-on, admin-revocable deny-list bit; admins bypass the bitmask entirely.
Where this leads
Section titled “Where this leads”That completes the governance picture — who is a member, how membership flows down the tree, and how capabilities resolve. Next, Applications, Bundles & Services: the code a context actually runs, and how it is packaged and bound.