# Relationships

Source: https://permdock.com/docs/concepts/relationships

Object hierarchies (nested folders, sub-teams, reporting lines, account delegates) as relation grants that walk a parent chain, decided in process through a RelationSource and in Postgres through a closure table.

[Named scopes](/docs/concepts/scopes) are tenancy: an organization, a workspace, a team, each a fixed level with its own memberships. Relationships are the object graph under them: folders inside folders, sub-teams, the manager of an employee, a delegate who acts for an account holder for a while. The graph has no fixed depth, so it is not a scope; a grant reaches an object because the subject holds a relation on it or on one of its ancestors.

## Declaring the graph [#declaring-the-graph]

A resource names its `parent`, the relations principals hold on it, and optionally a `restricted` column:

```ts
import {
  allow,
  definePermissions,
  definePolicy,
  relation,
  resource,
} from "permdock";

export const permissions = definePermissions({
  doc: resource(Doc, {
    actions: ["read", "update"],
    parent: { field: "folderId", resource: "folder" },
    relations: { owner: "ownerId" },
  }),
  folder: resource(Folder, {
    actions: ["read", "share"],
    parent: { field: "parentId", resource: "folder" },
    relations: {
      editor: { edge: "folder_editors" },
      viewer: { edge: "folder_viewers", expiresAt: "expires_at" },
    },
    restricted: "restricted",
  }),
});
```

A resource may parent itself (`folder` above): that is what makes a chain. Every walk over a chain has a depth, 16 unless the grant sets one, and never more than 32.

| Relation | Declared as | Held when |
| --- | --- | --- |
| Field | `owner: 'ownerId'` or `{ field }` | the row's field is the principal's id |
| Edge table | `{ edge, object?, subject?, expiresAt?, match?, groups? }` | a row of `edge` has the object id in `object` (default `<resource>_id`) and the principal id in `subject` (default `user_id`), `expiresAt`, when named, is null or in the future, and every `match` column equals its value |
| Principal | `{ principal, period? }` on a principal resource | the row's `principal` column is the principal's id and now is inside `period` (`startsAt`, `expiresAt` timestamp columns, `null` leaves a side open) |
| Implied | `{ includes: ['editor'] }` | the principal holds any relation in `includes` |
| Scope | `{ field, memberOf }` | tenancy, not the graph: a `through` grant never walks it |

`restricted` names a boolean column. A restricted object is reached only by grants on itself, never by relations held on its ancestors or through its [links](#links), and nothing below it is reached through it. Grants on the restricted object still reach its descendants.

### What a restricted row stops [#what-a-restricted-row-stops]

`restricted: { field, stops }` closes only the paths `stops` names: `'parent'` for the parent walk, and link names for grants that cross those links. The string form `restricted: 'restricted'` closes the parent walk and every link.

```ts
fileNode: resource(FileNode, {
  actions: ["read"],
  parent: { field: "parentId", resource: "fileNode" },
  links: { drive: { field: "driveId", resource: "fileDrive" } },
  relations: { viewer: { edge: "file_node_shares" } },
  restricted: { field: "restricted", stops: ["parent"] },
}),
```

With `stops: ["parent"]`, a share on a folder above a restricted node no longer reaches it, while everyone who reads the drive still reads the node and its subtree through `inherit(permissions.fileDrive.read, { through: ["drive"] })`. With `stops: ["drive"]` the drive link is closed and the parent walk passes the restricted row, so a share on a folder above it still reaches it.

A closed link stays closed below the restricted row. For a self-parented resource, a grant across a closed link does not reach a row when the row is restricted, when a row above it within 32 parent hops is restricted, or when its chain goes on past 32 hops; the last case denies with `relation-depth`, so a tree deeper than the check never opens by accident. A grant on the restricted row, or a parent walk from a row at or below it, still reaches its subtree. `definePermissions` rejects an empty `stops`, a link the resource does not declare, and `'parent'` on a resource without a `parent`.

### One edge table, several relations [#one-edge-table-several-relations]

`match` filters an edge table by fixed column values, so one `folder_members` table with a `role` column serves every relation on the folder. Values are strings, numbers or booleans.

```ts
folder: resource(Folder, {
  actions: ['read', 'update'],
  parent: { field: 'parentId', resource: 'folder' },
  relations: {
    editor: { edge: 'folder_members', object: 'folder_id', subject: 'subject_id', match: { role: 'editor' } },
    viewer: { edge: 'folder_members', object: 'folder_id', subject: 'subject_id', match: { role: 'viewer' }, includes: ['editor'] },
  },
}),
```

`includes` lists relations on the same resource that imply this one: every editor is a viewer. It can sit beside `field`, `edge` or `principal`, or stand alone as `{ includes: [...] }`, a relation held only through its list. `definePermissions` rejects an unknown name and a cycle.

### Groups [#groups]

`groups` lets an edge row name a group instead of a principal. `column` holds the group's resource name, `subject` its id, and the row holds for whoever holds `resources[<name>]` on that group. A null `column`, or the `direct` value, names a principal; any other value matches nothing.

```ts
const asGroupOrUser = { column: 'kind', resources: { team: 'member' }, direct: 'user' } as const

team: resource(Team, {
  relations: { member: { edge: 'team_members', object: 'team_id', subject: 'subject_id', groups: asGroupOrUser } },
}),
folder: resource(Folder, {
  relations: { viewer: { edge: 'folder_members', /* ... */ groups: asGroupOrUser } },
}),
```

Some share tables keep each subject kind in its own typed column, such as `user_id uuid` for a person and `team_id bigint` for a team, with no column naming the kind. Give such a group its own `subject` column with `{ relation, subject }`. Without `column`, a row names the principal in the edge's `subject` when that column is not null, and names a group when the group's own column is not null; a row with both set names both. With `column`, the group's id is read from its own column instead of the edge's `subject`.

```ts
drive: resource(Drive, {
  relations: {
    viewer: {
      edge: 'drive_shares',
      object: 'drive_id',
      subject: 'user_id',
      groups: { resources: { team: { relation: 'member', subject: 'team_id' } } },
    },
  },
}),
```

`definePermissions` rejects a group without its own `subject` when `groups` has no `column`, and a `direct` value without a `column`. RLS compares each group column as text, so the column types of the share table and the group's table need not match.

A team may be a member of another team. Nesting groups of the same resource stops after 16 levels; a group on another resource starts a fresh count. A cycle of groups of the same resource denies with `relation-depth`. A cycle across resources is rejected by `definePermissions`. Group resources must be declared in the same `definePermissions` call.

### Links [#links]

`links` names to-one references to other resources, so a grant can cross from a row to a related instance that is not its parent:

```ts
doc: resource(Doc, {
  actions: ['read', 'review'],
  parent: { field: 'folderId', resource: 'folder' },
  links: { folder: { field: 'folderId', resource: 'folder' } },
}),
folder: resource(Folder, {
  links: { team: { field: 'teamId', resource: 'team' } },
  // ...
}),

allow(permissions.doc.review, {
  to: relation(permissions.team, 'lead', { through: ['folder', 'team'] }),
})
```

`through` is `'parent'` or a list of link names followed in order, each on the resource the previous one reached. With a list, `depth` walks the last resource's parent chain from where the links end; without it the grant reads that one instance. Each link counts towards the 32-hop limit. A restricted row stops a link walk when its `restricted` closes that link, the default ([what a restricted row stops](#what-a-restricted-row-stops)).

## Granting through the graph [#granting-through-the-graph]

`relation(resource, name, options?)` keeps its signature. Without options the relation must be declared on the row's own resource and reads only the row. With `through: 'parent'` the grant follows the row's parent chain upward: to the parent resource when the relation is declared there, then along that resource's self-parent for up to `depth` hops.

```ts
export const policy = definePolicy(permissions, {
  grants: [
    allow(permissions.doc.read, { to: relation(permissions.doc, "owner") }),
    allow(permissions.doc.read, {
      to: relation(permissions.folder, "viewer", {
        through: "parent",
        depth: 16,
      }),
    }),
    allow(permissions.doc.update, {
      to: relation(permissions.folder, "editor", {
        through: "parent",
        depth: 4,
      }),
    }),
  ],
  subject: (user) => ({ id: user.id }),
});
```

An array in `to:` is an intersection, as everywhere in PermDock: `to: [relation(permissions.doc, 'owner'), relation(permissions.folder, 'viewer', { through: 'parent' })]` requires both. Two `allow` grants, as above, OR together. `definePolicy` rejects a `through` grant that cannot reach its resource (the row has no parent there, or a `through` on a resource that does not parent itself), a `through` over a `memberOf` relation, and an edge relation declared on another resource without `through`.

Reporting lines and delegates are the same two tools:

```ts
const people = definePermissions({
  employee: resource(Employee, {
    actions: ["review"],
    parent: { field: "managerId", resource: "employee" },
    relations: { manager: { principal: "managerId" } },
  }),
  account: resource(Account, {
    actions: ["act"],
    relations: {
      delegate: {
        principal: "delegateId",
        period: { startsAt: "delegateFrom", expiresAt: "delegateUntil" },
      },
    },
  }),
});

allow(people.employee.review, {
  to: relation(people.employee, "manager", { through: "parent", depth: 8 }),
});
allow(people.account.act, { to: relation(people.account, "delegate") });
```

The manager of an employee holds `manager` on it; walking the chain, the manager's manager holds `manager` on the manager, so skip-level managers reach the employee too. The delegate holds `delegate` only inside the period.

### A ceiling on a relationship [#a-ceiling-on-a-relationship]

A share often counts only while the person also holds a baseline permission in the organization: an external collaborator removed from the organization should lose every share inside it. Add `requires` to the graph grant:

```ts
allow(permissions.drive.read, {
  to: relation(permissions.drive, "viewer"),
  requires: permissions.file.read,
});
```

The share then counts only on drives whose organization is one where the subject holds `file.read` through a role, declared or custom, with no deny of it there ([requires](/docs/concepts/policies#requires)).

### Inheriting a permission through a link [#inheriting-a-permission-through-a-link]

A file node is often readable wherever its drive is, whoever made the drive readable: a role in the organization, a share, a group. `inherit(permission, { through })` grants on the row when the subject holds `permission` on the row a link (or the parent) points to, decided by every grant of that permission:

```ts
node: (resource(Node, {
  actions: ["read", "share"],
  links: {
    drive: { field: "driveId", resource: "drive" },
    folder: { field: "folderId", resource: "folder" },
  },
}),
  allow(permissions.node.read, {
    to: inherit(permissions.drive.read, { through: ["drive"] }),
  }));
allow(permissions.node.share, {
  to: inherit(permissions.drive.read, { through: ["folder", "drive"] }),
  where: { locked: false },
});
```

`through` is `'parent'` (the row's `parent` must be on the permission's resource) or a list of link names ending on it, as for `relation()`. The target row's own denies, conditions and `requires` apply, since the target is decided as `can(permission, targetRow)` would decide it. A restricted row inherits nothing through a path its `restricted` closes, and a row below it inherits nothing through a closed link. `definePolicy` rejects `inherit()` on a deny, on a collection action, for an undeclared permission, for a path that does not reach the permission's resource, and a chain of `inherit()` grants that comes back to a permission it started from.

In process the instance reads the target row through `RelationSource.row` and caches it like any other graph fact; `memoryRelations` answers it from `rows`. A source without `row`, or a read that fails, denies with `relation-unavailable`. The grant is server-only in snapshots, stays out of `where()` (`partial: true`) and `whoCan` (`complete: false`), and never matches as an approver or a delegation `from`, because each needs a row.

`permdock rls generate` compiles the grant to `"driveId"::text in (select permitted_drive_rows('drive.read'))`, carried over each further link by its link helper, and writes [`permitted_<resource>_rows`](/docs/adapters/rls#row-helpers) for every resource an `inherit()` targets whether or not `rls.rowHelpers` lists it. Each targeted helper is first written as an empty stub, so helpers that call each other are created in any order. The generated policy applies to `authenticated`, so an `anyone()` grant on the target does not reach `anon` through it.

### Resource roles down a tree [#resource-roles-down-a-tree]

A [resource role](/docs/concepts/tenancy) held on an instance of a self-parented resource reaches everything below it: a `folderAdmin` membership on `eng` applies to `eng`, to its subfolders, and to documents whose `parent` is one of them. In process this needs a `relations` source; without one the role applies only to the instance it names. RLS applies resource roles to that instance only (see [Row-level security](#row-level-security)).

## Evaluation [#evaluation]

A graph grant reads facts the row does not carry, so the instance asks a `RelationSource`:

```ts
type RelationSource = {
  ancestors(query: {
    resource: string;
    id: string;
    through: "parent";
    depth: number;
  }): RelationChain | Promise<RelationChain>;
  related(query: {
    resource: string;
    id: string;
    relation: string;
  }): RelationHolder[] | Promise<RelationHolder[]>;
  row?(query: {
    resource: string;
    id: string;
  }): Row | null | undefined | Promise<Row | null | undefined>;
};

createPermDock(policy, user, { relations: source });
```

`ancestors` returns the chain nearest first, at most `depth` entries, each with its own `restricted` flag, plus `truncated` when the chain goes on to a row that exists. A restricted row ends the parent chain after itself only when its `restricted` closes `'parent'`; otherwise the chain goes on and keeps the flags, which the check below a closed link reads. With a link name as `through` it returns the one instance the link points to. `related` returns the holders of one concrete relation on one object: `{ principal: { id } }` or `{ group: { resource, id, relation } }`, with optional `startsAt` / `expiresAt` in seconds. `row` returns one row by id, or `null` when there is none, for [`inherit()`](#inheriting-a-permission-through-a-link) grants. The instance expands `includes` and groups itself, so a source never answers for an implied relation. `memoryRelations(permissions, { rows, edges })` is the in-process source; `testRelationSource` in `permdock/testing` checks any other ([extension interfaces](/docs/concepts/extension-interfaces)).

Answers are cached per instance, never at module level: a request's `can`, `filter`, `whoCan` and the instances `tenant()`, `team()` and `simulate()` derive share one cache, and the next request starts empty. Evaluation stays synchronous. A source that answers synchronously is used directly; a Promise the instance has not loaded yet denies, and `await permdock.loadRelations(permission, rows)` loads what those rows need first:

```ts
const permdock = await getPermDock();
await permdock.loadRelations(permissions.doc.read, docs);
const visible = permdock.filter(permissions.doc.read, docs);
```

| Denial reason | When |
| --- | --- |
| `relation-depth` | The chain has a cycle, or it goes on past `depth` and no holder was found within it |
| `relation-unavailable` | No `relations` source, a call that threw or rejected, an answer that is not a chain or a holder list, or a Promise `loadRelations` did not load |

A graph read that fails fails the whole grant, whatever the rest of its condition says. On a `deny`, it denies the decision: a deny the graph cannot evaluate is never skipped.

## Snapshots, clients and where() [#snapshots-clients-and-where]

Snapshots carry no graph. A grant whose condition needs the graph, or whose relation has a `period`, is server-only in the snapshot (`portable: false`, no `where`), so a client check returns `opaque-condition` and the client stores (React, React Native, Vue, Svelte, Solid) route it to the decision endpoint, as they do for every server-only grant; `refresh` never carries relation facts. `mayAccess` stays an optimistic hint and answers `true` for a graph allow.

`permdock.where()` keeps graph grants as `related` nodes, and the ORM compilers turn them into SQL over the same tables RLS reads:

| ORM | How a `related` node compiles |
| --- | --- |
| Drizzle, Kysely | `toWhere(where, table, { relations })` emits one subquery: the closure table when `relations.closure` is set, otherwise a recursive walk bounded by the grant's depth |
| Prisma | `await resolveRelated(where, { run })` reads the ids with one raw query and replaces each node with `in(id, ids)`; `toWhere` then compiles as usual |

`relations: { tables, closure }` maps resource names to tables (default the resource name) and names the closure table, for example `'permdock.permdock_closure'`. Without `relations` the compilers refuse a `related` node with `non-portable-condition` ([conditions](/docs/concepts/conditions)). A relation with a `period` stays out of `where()` (`partial: true`). `resolveRelated` reads the ids when it runs, so a share added after that call is not in the result.

## whoCan [#whocan]

`await permdock.whoCan(permission, row)` lists who holds a permission on one object and how, for share dialogs and access reviews:

```ts
const { holders, complete } = await permdock.whoCan(
  permissions.folder.read,
  folder,
);
// holders: [{ principal: { id: 'u_vera' }, via: [{ kind: 'share', resource: 'folder', relation: 'viewer', id: 'root' }] }]
```

`via` is `role` (with the membership, from `MembershipSource.list`), `relation` (a field or principal relation, on the object or an ancestor) or `share` (an edge-table row, with its `expiresAt`, and `group` when the row named a group the holder is a member of). Implied relations are expanded, so an editor is listed under a viewer grant with the `editor` relation in `via`. Each candidate is confirmed with a full decision, so a deny, a restricted branch or an expired share removes them. It lists and never grants. `complete` is `false` whenever a grantee cannot be enumerated: a global role, a plan, `anyone`, `authenticated`, an assurance or actor grantee, a custom-role grant, a resource role, a membership source without `list`, a relation the source could not answer, or a grantee kind this build does not know. A `false` list may miss holders, and is never presented as complete.

## Row-level security [#row-level-security]

`permdock rls generate` emits `<schema>.permdock_closure(resource, ancestor, descendant, depth)`, kept current by statement triggers on each self-parented table the policy walks, and compiles a graph grant to one uncorrelated subquery over it. `match` becomes a column predicate, `includes` an `or` over the implying relations, groups a recursive CTE bounded at 16 levels, and each link a `security definer` helper `permdock_link_<resource>_<link>(ids text[])`. See [closure table](/docs/adapters/rls#closure-table) and `permdock rls verify --tree`.

Limits of the SQL side:

* Resource roles apply to the instance the membership names, not to its descendants.
* An edge `expiresAt` compares against `now()` and must be a `timestamptz` column.
* Helper names use the resource's snake\_case name: `chatThread` gets `permitted_chat_thread_ids`. A graph resource named `team` clashes with the implicit `team` scope (doctor PD032); declare `scopes` or rename the resource.
* Hosted grants (`permdock/cloud`) reject a link list in `through`.

## External graphs [#external-graphs]

OpenFGA and SpiceDB are recipes, not package entries ([ecosystem index](/docs/research/ecosystem-index)). A `RelationSource` over either answers `related` with a read of the tuples on one object (OpenFGA `read`, SpiceDB `ReadRelationships`) and `ancestors` with the parent tuples walked up to `depth`; the model keeps the graph, PermDock keeps the decision, and RLS still needs the rows in Postgres. To hand the whole decision to the external engine instead, `permdock/pdp` ships `openfga()` and `spicedb()` decision providers.

## Why [#why]

* **Parent chains, not a tuple store.** A Zanzibar-style relation store leaves the subset the database can enforce. A parent pointer on the row and relations on the resource node compile to a closure table and one subquery, so the same grant is enforced by `can`, by the database and by `rls verify`. Teams that already run OpenFGA or SpiceDB plug them in as a source.
* **Depth is bounded, and past it means denied.** A chain with no bound is a denial-of-service vector and a cycle is an infinite loop. The walk reads at most `depth` ancestors (16 by default, 32 at most); a holder within them grants, anything beyond is never read, and when nothing within them holds the relation the denial says `relation-depth` so an operator can tell a cut-off chain from a missing share. A cycle always denies. In Postgres the triggers refuse a write that makes a row its own ancestor, so the database never holds a cycle to disagree about.
* **Restricted stops inheritance at the row.** The common need is a folder inside a shared tree that only its own members see (HR under the company root). Stopping the walk at the restricted row, after reading its own relations, gives that without a deny, and the closure triggers stop at the same row, so the database agrees.
* **A restricted row names what it stops, and a closed link stays closed below it.** A file tree inside a drive has two inherited paths: shares on folders above, and access to the drive. Some apps want a restricted folder hidden from shares above it but still visible to the drive's members; others want it hidden from both. One column that closed every path forced the second meaning on everyone, and checked a link only on the row itself, so the restricted folder disappeared from the drive while its unrestricted children stayed visible through their own drive link. `stops` lets the resource choose, and checking the rows above for a closed link makes a subtree follow its restricted root. The string form keeps closing every path and now applies the check below too: this is a breaking change for trees that relied on children of a restricted row staying reachable through a link, and it fails closed. The check reads the closure in Postgres and the parent chain in process, both 32 hops deep, the deepest any walk goes.
* **Evaluation stays synchronous.** `can` runs on every render and inside `filter`; making it async for one grant kind would split every adapter. The limit store set the precedent: a Promise is never awaited on the decision path. `loadRelations` is the explicit async step, and it only fills the cache.
* **Graph facts stay on the server.** A snapshot that listed every folder a user can see would be a cache that goes stale on every share and leaks the tree's shape, so graph grants resolve on the server. Relation periods follow them, since a client clock is not the server's.
* **Roles, implication, groups and links, but no tuple store.** These are the four patterns the parent chain could not express (Google Drive's viewer-implies-commenter, teams as members of folders, a role column on a membership table, a document reviewed by its folder's owning team). Each compiles to a column predicate, an `or`, a bounded recursive CTE or a to-one join, so `can`, the ORM filters and RLS still agree. Arbitrary userset rewrites and intersections over relations stay with OpenFGA and SpiceDB.
* **Group nesting counts per resource.** A team inside a team inside a team is a chain of the same resource and gets the same 16-level bound as a parent chain. Crossing to another resource (a folder shared with a team) starts a new count, since that crossing is fixed by the declaration and cannot loop; `definePermissions` rejects the declarations that could.
* **A shared group is walked once.** Teams that each contain the same sub-teams form a lattice whose paths grow exponentially with its depth, while its groups grow linearly. `can` remembers every group it has walked and the depth budget it had, and skips one already walked with as much budget, since that walk's verdict is already counted; `whoCan` keeps each group's member list per budget. The cycle check stays per path, so a cycle still denies with `relation-depth`.
* **Inheritance names a permission, not a relation.** "A node is readable where its drive is" is a statement about every way the drive becomes readable. Restating each of them as relation grants on the node, or ORing helper calls in hand-written policies, drifts as soon as the drive gains a grant. `inherit()` asks the target permission itself, through the same row helper in SQL and the same decision in process, so the two stay one definition. Requiring the inheritance graph to be acyclic keeps both the recursion in process and the helper calls in SQL finite.
* **ORM filters use SQL, Prisma uses ids.** Drizzle and Kysely accept raw SQL fragments inside a typed query, so the graph subquery runs inside the list query. Prisma's `where` input has no raw fragment, so `resolveRelated` reads the ids first. The same SQL builder feeds all three and the RLS helpers, and `tests/integration` checks each against `can()`.
* **whoCan says when it is incomplete.** An access review that silently drops holders is worse than none. Global roles and plans are not enumerable from a row, so the list says `complete: false` instead of guessing.
