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.
The line, stated plainly
Section titled “The line, stated plainly”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:
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).
Turning it on
Section titled “Turning it on”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']); // admindirectory.setCapabilities('acme', 'jo', ['write']); // contributordirectory.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.
What your app sees
Section titled “What your app sees”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.
What this does not do
Section titled “What this does not do”- 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
writecan 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.