ADR-0011 — a member's fields are written through per-field routes, each with its own grant
Date: 2026-10-01 Status: accepted
Context
The registry's member writes were granted in two sizes: members.write, which creates members
and rewrites any field including user_id, did and status, and members.profile.write, a
one-off narrowing for role and area (ADR-0003). Each new caller needing less than
members.write produced another one-off. Two now did at once:
- the community dashboard's backend writes role and area, and will write nothing else of a member;
- onboarding corrects a member's name and delivery points after approval. It holds
members.writeonly because creating members needs it, and the correction does not.
ADR-0003 already settled two things that constrain the answer. Authorisation is derived from the route's fixed segments and the method, never the body: the middleware decides before anything is parsed, and a body-driven check would make authorisation depend on parsing untrusted input. And a narrower grant is a superset rule in the policy, not a change to the shared scope matcher.
Decision
- One sub-route per field group, one action per route, one scope per action.
PUT …/members/{mk}/name,…/roleand…/area, each taking exactly its one key, derivemembers.name.write,members.role.writeandmembers.area.write;PUTandDELETE…/members/{mk}/delivery-points/{id}derivemembers.delivery_points.write. The scopes arerec-registry.<action>. - Naming keeps the existing order,
<resource>.<field>.write, asmembers.profile.writealready does, sorec-registry.adminkeeps covering every action through the shared matcher. Where the route segment is hyphenated (delivery-points) the action uses an underscore. - Each action's rule accepts the grants that already wrote that field:
members.writeand.adminfor all four, andmembers.profile.writealso for role and area. No caller loses a write when a narrower route appears. - Identity stays coarse. Creating a member,
user_id,did, status and the generalPATCHstaymembers.write: those bind a person to a member, and only onboarding holds them. There is no narrow route for an identity field, and a segment not in the list derivesadmin. PATCH …/profilestays, as the role-and-area alias of the two field routes, so no caller breaks.- The value checks are shared. A field route calls the same check and the same write as the
general
PATCH; the narrow door is never a looser one. - The query is not read either.
?replaces=on the delivery-pointPUT(ADR-0012) changes what the write does, not who may do it. - Extension is a segment, a rule and a scope. A new field group is one entry in the
middleware's
_MEMBER_FIELD_ROUTES, one rule inaccess.rego, one scope declared in../celine-policies, and the route — plus its hostile-id cases and its place inTestNoWriteReducesASibling.
Consequences
The action set grows to fifteen, and REQ-0010's set check with it; every rule carries
.admin, which a test reads.
More routes, each a one-key body. That is the cost of never reading a body for
authorisation, and it is paid in the SDK: each route is a wrapper. The general PATCH is not
removed, so a members.write holder may keep writing several fields at once.
The grants are still registry-wide. A holder of rec-registry.members.area.write can set
the area of any community's members; keeping a manager to their own community stays the
dashboard's policy.
Granting moves to ../celine-policies. The registry declares the actions; which client
holds which scope is decided there. Until a client is moved to the narrow scope, it keeps
working through its superset.
The temptation is to collapse the field routes into one PATCH with a body-derived
action the next time a field is added. ADR-0003's reasons still hold; the extension recipe
above is the shape this service keeps.