Status: accepted · Date: 2026-10-31 · Related: ADR-0028 (privilege matrix), ADR-0002 (leaf modules)
Enforcement: packages/api/src/features/team/team.rules.test.ts (table-driven matrix, full-frame cases, member ≡ nurse), packages/api/src/platform/rpc/require-org-role.test.ts (care roles reach the clinical boundary; member is read as nurse), packages/auth/src/invitation-policy.test.ts (only owner/admin hold invitation:create; every care role is a configured role).
A Wundklar Organization is a care service (PRD §1.5). Its Members hold one of five roles — owner/admin (PDL), wound_expert (Wundexpertin ICW), nurse (PFK), aide (PHK) — and every clinical procedure, sync handler and screen asks what that role may do. The template inherited Better Auth's three organization roles (owner/admin/member) with their generic access-control statements; this ADR records how we add the care roles without re-inventing Better Auth's AC and without threading role strings through the service layer.
Decision
-
Care roles on the Better Auth organization plugin.
packages/auth/src/care-roles.tspassesroles = { owner, admin, wound_expert, nurse, aide, member }toorganization().ownerandadminkeep Better Auth's default statements (so they holdinvitation:create); the three care roles and the templatemembershare adefaultAc.newRole({ ac: ["read"] })role — no write statement in the Better Auth sense. Only owner and admin may invite, change roles or deactivate (invitation policy,packages/auth/src/invitation-policy.ts); every care role is a valid value for theinvite-memberendpoint'sroleparameter. -
memberis read as nurse. The template defaultmemberstays a configured role so an older seed still passes, butrequire-org-roleandteam.rules.can()normalize it tonurse. The clinical boundary speaks in care-role terms only; nothing writesmemberany more. -
The permission leaf.
packages/api/src/features/team/team.rules.tsexportsCARE_PERMISSIONSandcan(role, permission, policy?), the single answer every seam asks. The matrix of plan §1.5 is a static table;canreturns a boolean. The one policy-dependent cell isphoto:intimate-full-frame. -
Full frame of intimate photos. The D-Team screen is the normative one (coordinator decision, plan §5 question 1): wound expert by default; the PDL may grant the PDL role (
owner,admin) through the org policyfullFrameRoles(set in ticket 19); nurse and aide never.can("wound_expert", "photo:intimate-full-frame")is always true;can("owner" | "admin", …, { fullFrameRoles })is true only when the policy lists the role. The legal export (MD audit, Art. 15) always includes originals and is a separate permission (export:legal) held only by owner/admin. -
can()crosses the "no role strings in services" rule, by design. ADR-0011 keeps role strings out of services. The sync dispatcher is the one documented exception: a mutation arrives with the sender's role on the request context, and the dispatcher askscan(role, permission, policy)before routing to the feature handler. Features themselves still never read role strings. The exception is spelled out here so the next reader does not undo it.
Consequences
- Later tickets gate every clinical seam with the same
can()call; adding a permission is a one-line change in the matrix with the test row that pins it. - A role rename or a new role (future
trainee?) is a change incare-roles.tsand the matrix; nothing else moves. fullFrameRolesis a policy row, not a role attribute: the PDL changes it from the D-Team screen without migrating membership.- Older seeds that stored
memberstill work:require-org-roleandcan()map it to nurse at the edge.
Alternatives rejected
- Mint a care-role AC with full write statements per permission. Better Auth's AC was built for CRUD on its own entities; mapping clinical permissions (
assessment:record,photo:intimate-full-frame) onto member/organization/invitation statements would be a lossy translation.can()reads a readable matrix instead. - Hierarchy (admin > wound_expert > nurse > aide). The matrix has no clean inclusion: a wound expert may approve reviews but may not manage the team; a nurse may propose a plan but may not activate it. A flat list avoids ordering bugs.
- Role strings in each feature's service. Would duplicate the matrix across features and make a product-wide change (say, letting nurses activate plans in a pilot) a sweep. Keeping it in the sync dispatcher is a one-point change.