Hierarchical Grants a Child Can Never Exceed Its Parent
How Matrix AND-composes a principal's grant with every ancestor's up the reportsTo chain, making delegated admin safe by construction.
Delegated administration is where most access-control systems quietly spring a leak. You let a team lead add their own members. They author a grant for one of those members. And now you have to answer a hard question on every single write path: is the grant they just wrote a subset of what they themselves are allowed to see?
The naive answer is a validation function — isSubsetOf(childGrant, parentGrant) — run at authoring time. It is genuinely hard to get right. Row filters compose by intersection, field masks by set intersection, op rights by boolean AND, and your validator has to model all of that, for every entity type, before you let the write through. Miss a case and a sub-admin grants someone more than they have. Privilege escalation by paperwork.
Matrix doesn't write that validator. It makes the question moot.
This is the third post in the Security & Governance series. If you haven't read Row, Field, and Type Security for AI Agents, start there for the four enforcement dimensions; this post is about how those dimensions compose across a hierarchy. The primary keyword here is hierarchical rbac, and the whole idea fits in one sentence: a child can never exceed its parent.
The grant, recapped
Each principal — human or agent — is a Membership row. For each entity type it carries a GrantSpec:
{
"rowFilter": [ /* conditions, AND-ed */ ] | null, // null ⇒ type NOT accessible
"readFields": ["*"] | ["fieldA","fieldB"], // "*"/omitted = all
"writeFields":["*"] | ["fieldA"],
"canCreate": true, "canUpdate": true, "canDelete": false
}
GrantSpecs live as a JSON object { entityType: GrantSpec } in Membership.grantsJson. That's the authored grant — what someone typed into the form. It is not, by itself, what the principal can do.
Effective access = AND up the chain
The grant Matrix actually enforces is the effective grant: a principal's own GrantSpec AND-composed with every ancestor's up the Membership.reportsTo chain, all the way to the org root.
Each dimension composes the only way that preserves the "can't exceed" property:
rowFilter→ concatenated (logical AND). Every ancestor's row conditions are appended; the row must satisfy all of them.readFields/writeFields→ intersected. You can read a field only if you and every ancestor can read it.canCreate/canUpdate/canDelete→ AND-ed. One ancestor withcanDelete: falseand nobody below can delete.- A
null(no-access) anywhere in the chain ⇒ no access, full stop. One ancestor who can't seeLeadmeans nobody reporting through them can either.
Notice the asymmetry that makes this work: every operation either narrows or leaves equal. Concatenating AND conditions can only shrink the result set. Intersecting field sets can only remove fields. AND-ing booleans can only turn a true into a false. There is no compose operation that widens. So the composed grant is monotonically capped by the most restrictive link in the chain.
The invariant, stated plainly
A child can never exceed its parent.
Because composition only ever narrows, whatever a member authors for someone below them gets AND-capped against their own effective grant at enforcement time. If a team lead can only see Lead rows in one region, and they author a child grant that says "all regions," the runtime still appends the lead's region filter on top. The child sees one region. The over-broad authoring is harmless — it was never the source of truth.
That is the entire reason delegated "add member" is safe by construction. When you add a member through /admin/users, the new Membership is created with reportsTo = creator. That single field is the whole safety mechanism. There is no separate isSubsetOf validation at authoring time, because there is nothing to validate: the runtime caps the child at the creator's access on every read and every mutation, regardless of what the grant text claims.
Delegated administration, with zero trust in the delegate's grant authoring. The structure does the enforcing, not a policy check that you have to keep correct forever.
The condition language
A rowFilter is a list of conditions, all AND-ed within a single grant (and, per the section above, AND-ed again across ancestors). Here is a grant that scopes a recruiter to their own region, under a budget cap, on records assigned to their team:
[
{ "field": "preferredState", "op": "eq", "value": "North" },
{ "field": "feesPerYear", "op": "range", "value": { "max": 300000 } },
{ "field": "assignedAgent", "op": "in", "value": "$selfAndTeam", "ref": true },
{ "field": "$id", "op": "self", "value": "$self" }
]
The pieces:
- ops:
eq·in·contains(case-insensitive substring) ·range({min,max}) ·isNull·self(the row's own node id equals the principal — "my own record"). - bindings, resolved per principal at query time:
$self= the principal's id;$selfAndTeam= the principal plus its team members' ids (viaMembership+ shared team). You write the binding, not a hardcoded id, so the same grant means the right thing for each member. ref: truemarksfieldas an ENTITY reference — a:HAS_FIELDrelationship — so the condition matches against the referenced node id(s) instead of a native scalar property. TheassignedAgentline above uses this: it matches the related agent node, not a string column.
Compiled two ways, enforced once
A condition isn't a single artifact. Each one compiles to both:
- A Cypher
WHEREfragment, spliced directly into read queries — including the raw-Cypher aggregate reads, so acountor asumcan't leak rows the principal can't list. - A Java predicate, used for single-row checks (
getEntity) and for mutation gating (can this principal update this specific row?).
This dual compilation is why reads, single-row fetches, and writes all enforce identically. There's no path where the list endpoint filters but the by-id endpoint forgets, or where a mutation slips past because the filter only existed in the query layer. The same condition, two representations, one meaning — and the composition across the reportsTo chain happens before either representation is generated, so the hierarchy is baked into both.
It all runs centrally in EntityManager, which is the chokepoint every data path goes through: the admin dashboards, the generic /api/entities API, the find_records tool, and every agent tool. Add a new read path and it inherits the composed, hierarchical grant for free — there's no per-caller wiring to remember.
Where agents fit
Agents are Membership principals too, so the same composition applies to them. The wrinkle is whose chain gets walked. When an agent runs a tool, Agent.mode decides scope: an AUTONOMOUS agent runs under its own grant (and therefore its own ancestor chain); an INTERACTIVE agent runs under its grant intersected with the on-behalf-of caller's. Either way, the result is a composed, capped grant — an interactive agent can never surface, through the model, data the human caller couldn't see. That intersection is its own topic; Agents as Principals covers it end to end.
Authoring it
From /admin/users → Add member, each entity type gets one of three settings:
- Inherit — same as you. Omit the grant entirely; the member's effective access for that type is whatever yours composes to.
- No access —
rowFilter: null. The type vanishes for them (and for anyone below them). - Restrict — pick create/update/delete, choose read/write fields (
*or a list), and build row-filter conditions with the visual builder, scoping with$self/$selfAndTeam.
The new member is created under you (reportsTo = you), so — to say it one more time — their effective access is capped at yours no matter what you select. You can author too-broad and the runtime quietly tightens it; you cannot author too-broad and have it take effect.
Two notes on scope. Access control is opt-in per org (Organization.accessEnabled, default off) behind a master kill-switch — a tenant behaves exactly as before until an OWNER turns it on. And v1 composes a single reportsTo chain: multi-membership principals (a member reporting up more than one chain) are deferred. One member, one chain, one capped grant.
Takeaway
The subset-validation approach asks "is this authored grant safe?" on every write and has to be right every time. The hierarchical-composition approach never asks, because the runtime AND-caps a child against its parents at enforcement time — across row filters (concatenated), fields (intersected), and ops (AND-ed), with a null anywhere collapsing to no access. Delegated administration becomes safe by construction: set reportsTo, and the invariant holds itself. No validator to write, no validator to keep correct.
If you're handing out admin to team leads and losing sleep over what they can grant, the fix isn't a better checker — it's a structure where the dangerous grant can't take effect. The full model, condition reference, and rollout steps are in docs/ACCESS_CONTROL.md and the engine lives in AccessControlService.
Ready to delegate without the audit anxiety? Create a workspace, enable access on an org with PUT /api/orgs/{slug}/access, and add a restricted member under yourself — then log in as them and watch the runtime cap exactly what you'd expect.
Build your first agent on Matrix
Spin up a workspace, wire up tools and knowledge, give your agent a voice, and talk to it in real time — no agent code required.