status column (the “pending” state lives entirely on the invitation). A member row always means “this user actually works here.”
The workspace’s owner never has a member row — owner and member rows are disjoint (see Workspace → Ownership).
Properties
The two arrays: capabilities vs permissions
The single most important sentence for developers:
Sublay understands and enforcesThey never mix.capabilities; Sublay is blind topermissions.
capabilities is a fixed set Sublay uses to gate its own workspace plumbing (who can invite, who can remove members, etc.). permissions is whatever your app means by “can deploy,” “can edit,” etc. — Sublay stores, grants, revokes, and returns them, but never validates or interprets which strings you use.
capabilities (the closed vocabulary)
There are exactly 7 capabilities. Sublay ships no built-in owner/admin/member role presets — you compose your own role concepts from these keys (the “keyring” model).
The four people-operating capabilities (
invite, remove-member, edit-member-access, edit-member-profile) additionally grant visibility of other members’ authority.
Two tiers, not one. Reading the roster at all —
GET /workspaces/:id/members and GET /workspaces/:id/members/:userId — needs only some relation to the workspace (owner, ancestor-owner, member, or reach-holder); that tier shows who is there, plus each member’s title and metadata.The authority-bearing fields — capabilities, permissions, rank, relativeRank — are omitted from other users’ entries unless the caller holds one of the four people-operating capabilities above, or is the owner / an ancestor-owner (a service/master key acting as itself is never fenced; one naming an actingUserId is fenced exactly as that user is). A caller always sees their own — their own roster row, their own standing, and the authority read — so no one is ever blind to their own access.That gate is per node, not per request. It is answered separately for every workspace whose members a response describes, against your resolved standing on that workspace — which includes capabilities reaching in through an unbroken open inherit chain and ownership from any ancestor, but not authority that stops at a sealed boundary. So on GET /workspaces/:id/members?include=descendants, each descendant-member row is judged on the descendant it belongs to: operating people on the parent does not unfence a sealed child, and the roster never shows you more of a node than that node’s own roster read would.relativeRank is fenced with rank rather than beside it, because it is rank minus a number the caller already knows — handing it to a fenced caller would hand them rank itself. Note that fencing and presence are different questions on descendant rows: relativeRank is uniformly absent there for everyone (ranks from two ladders cannot be subtracted), while the fence above is decided node by node.title.
Capabilities are resolved across the tree — a member’s effective set includes their direct capabilities, plus any reaching in from ancestors via an open inherit-chain, plus the full vocabulary if they are an owner/ancestor-owner. See reach and the authority read.
permissions (opaque)
A free-form array of developer-defined strings, opaque to Sublay. Unlike capabilities, permissions do NOT cascade down the tree — they are strictly per-node. A read of a user’s standing returns the permissions stored on their direct membership on the target workspace only; an ancestor membership’s permissions never appear. If you want a parent permission to imply a child one, that is your app logic to add.
Rank semantics
rank is a per-workspace integer where a smaller number = more senior. The owner is the apex, above rank 0 (not a numbered rank).
- The owner mints rank 0 (only an owner can mint rank 0); each level appointed below gets the next number up (1, 2, 3, …). The top is anchored at 0 and the ladder extends downward without a ceiling.
- Act rule: a member may only strip capabilities from, remove, or re-rank members who are strictly less senior (a strictly larger rank number). Equals cannot act on equals.
- Assign rule: you may only set another member’s rank to a number strictly larger than your own.
- Initial rank is chosen on the invitation.
Two coordinates: rank and relativeRank
Both governance rules above are relative — act only on someone strictly below you, assign only strictly below yourself — so the API lets you name a rank in either coordinate:
The ceiling is the ladder’s storage type (
int4). Both the value you send and the resolved sum are bounded, so an offset that is legal on its own but overflows once anchored — relativeRank: 1 from a rank-2147483647 actor — is a 400 workspace/invalid-body at the edge rather than a 500 from the database.
They are mutually exclusive — sending both on the same write is a 400. Storage is always absolute: a relative offset is resolved to an absolute number at write time and that is what the member row holds.
On reads, the same field runs the other way: the roster and member-standing reads return relativeRank alongside rank, expressing that member’s position as an offset from you. (The authority read carries rank only — its subject is you, so an offset from yourself could only ever be 0.) The read domain is wider than the write domain — -3 means “three rungs above you”, 0 means “your peer”. That asymmetry is one validation rule, not two concepts.
The anchor: your member row, not what kind of actor you are
A relative offset is measured from the caller’s own row on this workspace if they hold one, and from apex (one step above rank 0) if they do not. This makes “only the owner mints rank 0” fall out of the rules instead of being a separate guard — there is no rank-0 check anywhere in the bundle. Through the relative form the arithmetic settles it: an in-ladder anchor is>= 0 and a write offset is >= 1, so a direct member can never produce rank 0. Through the absolute form the assign rule settles it: 0 is not strictly larger than any rank >= 0, so an actor with a row here is refused (403 workspace/insufficient-rank). Rank 0 is therefore reachable by the owner — who skips the rank rules entirely — and by any other actor with no row on this node, who skips them too and anchors at apex.
A relative offset is a snapshot, not a live link
relativeRank on a read is the opposite: computed fresh on every request against whoever is asking, so two callers reading the same roster see different relativeRank values for the same member. Only rank is stable across callers.
No privilege escalation
- Granting a capability or permission string requires the actor to already hold it on the target node (their resolved set there, including via reach).
- Stripping/removing a capability or permission is governed by rank alone (strip anything from a lower-ranked member; you need not hold it yourself).

