Skip to content

Part 15 — who is allowed to do what

Signatures answer who wrote this? Until now, nothing answered were they allowed to? Inside a shared graph, every member was therefore effectively an owner: anyone holding the key could rotate it out from under everybody or overwrite anyone’s work, and the only thing stopping them was your UI not offering the button.

This part is about what can honestly be enforced, which is narrower than a role matrix and worth understanding before you design around it.

A member who holds a graph key can read it. That is arithmetic, not policy, and no permission system changes it. Anyone who tells you otherwise is selling you a UI.

What a member cannot do is reach another device except through the relay. So the relay is the only place a permission is real, and the operations it mediates are the ones that can be prevented rather than disapproved of afterwards:

not preventable — no chokepoint exists

reading what they hold the key for

overwriting a record
the relay cannot see whose it is

which KIND a record is
the relay cannot read it

preventable — the relay mediates it

push a record

upload or delete a blob

mint a new key epoch
(rotation, and therefore removal)

refused: 403 not-permitted

your app's convention, and nothing more

Two capabilities, because those are the two that map onto operations the relay can actually see:

capability what it gates who needs it
write pushing records, uploading and deleting blobs contributors and up
rotate minting a new key epoch — how somebody is removed admins

Which gives the three seats an organisation actually asks for: read-only (neither), contributor (write), admin (both).

argala does not own your roles, just as it does not own your users. Roles arrive through the same Directory seam as membership:

import { SqliteDirectory, type Capability } from '@argala/relay';
// Using argala's own directory:
directory.setCapabilities('acme', 'sam', ['write', 'rotate']); // admin
directory.setCapabilities('acme', 'jo', ['write']); // contributor
directory.setCapabilities('acme', 'auditor', []); // read-only
// Or against the roles you already have — an IdP, a permissions table:
class MyDirectory implements Directory {
async capabilitiesOf(groupId: string, memberId: string): Promise<Capability[] | null> {
const role = await myRoles.for(groupId, memberId);
if (!role) return null; // unmanaged: everything, as before
return role === 'admin' ? ['write', 'rotate'] : role === 'member' ? ['write'] : [];
}
}

A directory that does not implement capabilitiesOf grants everything, and so does returning null. That is deliberate: a permission model that silently locks out every existing user on upgrade day is not a feature. null also lets you govern one group and ignore the rest.

Personal and pairwise graphs are never asked about. u has one member and d: has two peers, so a capability check there could only lock somebody out of their own data.

A refused push does not throw and does not stop the sync:

engine.on('error', (e) => {
if (e.scope === 'policy.readonly') showReadOnlyBanner(e.graphId);
});

The queue is held rather than dropped, and the pull still runs. That detail is central to the design: a client that threw here would abandon the sync run before reading, so a read-only seat would slowly stop receiving other people’s records, turning a permission feature into a disconnection. If the seat is widened later, the held writes are pushed.

Do not rely on the banner alone. A read-only member’s app should not offer the button; the relay’s refusal is the backstop for the stale tab, optimistic UI, or forgotten background job.

  • It does not stop a member reading. They hold the key. If somebody must not read a graph, they must not be in it — remove them and rotate.
  • It does not give per-record ownership. A member with write can overwrite or delete another member’s records. Deciding otherwise means knowing whose record it is, and the relay cannot read one to find out.
  • It does not restrict by kind or shard. Same reason.
  • It is not a defence against a malicious relay. The relay asks the question and applies the answer, so a hostile one grants itself whatever it likes. This stops a member, which is the adversary an organisation actually has — and it composes with the checks that do watch the relay (Part 13).
  • It is not retroactive. Demoting somebody to read-only stops their next write. Everything they already wrote stays exactly where it is.