ADR-0031: Sub-verb matrix entries for role-only resource guards
Metadata
- Status: Accepted
- Date: 2026-08-16
- Deciders: Eagraí Clainne Team
- Related: ADR-0002 (centralised RPC authorization), ADR-0009 (service layer authorization), ADR-0014 (item role rules), ADR-0015 (create into list), ADR-0028 (clients gate affordances on GetPermissionMatrix)
Context
ADR-0028 made SystemService.GetPermissionMatrix the one source clients
read to decide which controls exist. That works for rules the matrix can
express: one service/method entry, a set of allowed roles.
But some handlers hold a second, role-only rule the matrix could not see.
ItemService/Create allowed CHILD, because a child may add entries to a
list (ADR-0015) and steps under their own job (ADR-0025). Inside the
handler, a hard-coded role check then refused a child a standalone job.
The clients gated the "New job" button on the only row they had —
ItemService/Create — so both web and Android showed a child the button
and the server refused the create: "Not authorized to create an item
outside a list."
Two rules lived in two places. The matrix said yes, the handler said no, and every client repeated the drift.
Decision
A role-only resource guard inside a handler is named in the
PermissionMatrix as a sub-verb entry: a normal Permission row whose
Method is the RPC method plus a dotted suffix, for example
ItemService/Create.standalone.
- The handler consults the entry via
roles.CheckPermission(service, subVerb, requester.Roles)instead of a hard-coded role list. GetPermissionMatrixserves the entry with the rest of the matrix — no wire or proto change; thePermissionmessage already fits.- Clients gate the matching affordance on the sub-verb row, exactly as ADR-0028 has them gate on real methods.
The dot is the marker: RPC method names never contain one, so a sub-verb row can never collide with, or be looked up as, a real method by the auth interceptor. The completeness test is unaffected — it sweeps generated RPC descriptors toward the matrix, and extra rows are legal.
Sub-verbs carry role-only rules. A guard that needs the resource (ownership, event membership, list scoping) stays in the handler as before — the matrix cannot express "their own", and pretending it can would lie to clients.
The first sub-verb entry also changes policy: Create.standalone allows
Admin, Member, Child — children may now mint standalone jobs, not only
list entries and steps under their own jobs. The guard machinery stays:
the row is the policy, and tightening it later is a one-line change that
every surface follows automatically.
Consequences
- One row now carries the standalone-create rule for the server guard and every client. A policy change edits the matrix and nothing else.
- The "New job" affordance on web and Android reads
ItemService/Create.standaloneand appears for children. - Clients that never learned the convention keep working: an unknown row is ignored by lookup, and gating on the plain method remains safe but coarse.
- The matrix now contains rows that are not RPCs. Anyone treating every row as a callable method (docs generators, audits) must learn the dotted convention.
- AGENTS.md records the convention: new role-only handler guards get a sub-verb row, never a hard-coded role list.
Amendment (2026-08-18)
Three more role-only guard halves join the matrix, closing the proxy-verb
idiom the RBAC review found (clients electing one verb — usually
ItemService/Delete — to stand for a whole tier):
ItemService/Update.other(Admin, Member) — the role half of the ADR-0014 item-manager guard.svc.RequireItemManagernow consults this row instead of a hard-coded role pair; clients gate manager affordances (edit, reassign, repeat, step management) on it, mirroring the guard's ownership half locally (own or unowned passes).RewardService/Claim.other(Admin) — who may claim a reward on another member's behalf. The Claim handler consults it.SystemService/ExportUserData.other(Admin) — the ADR-0027 admin half of non-self export, via the newsvc.RequireSubVerbhelper.UserService/Update.other(Admin) — who may update a member other than themselves; the per-field mask stays in the handler.
The rule the amendment sets: a shared guard's role half lives in exactly one row, and the guard itself consults that row — so the row is the policy for the server and every client alike, and a future tier split changes one line.
Alternatives considered
- Role check in clients (gate "New job" on Admin|Member): quickest, but re-introduces the hand-kept role bucket ADR-0028 removed, in three places, with no server tie.
- Split the RPC (
CreateStandaloneItem): honest matrix semantics, but a breaking proto change, a second create path to keep in lockstep on four surfaces, and the request shape (list_uidoptional) already models the union naturally. - A dedicated capabilities RPC ("what may I do, with context"): strictly more expressive (could answer ownership questions too), but a new service to design and cache for a problem the existing matrix transport already solves for role-only rules.