App reference

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.
  • GetPermissionMatrix serves the entry with the rest of the matrix — no wire or proto change; the Permission message 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.standalone and 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.RequireItemManager now 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 new svc.RequireSubVerb helper.
  • 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_uid optional) 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.