ADR-0014 — Per-staff permission overrides
- Status: Accepted
- Date: 2026-05-06
- Decision-makers: Tom Anderson
Context
The first implementation of staff permissions used a pure role-based model: role_permissions(role_id, screen_id, can_see). Toggling a tab visibility for one staff member changed it for everyone with the same role.
The shop owner pointed out that this is wrong: "When I check the tabs they can see, it sets that for every staff with that role." Real shops have staff who are nominally the same role but with different responsibilities. Junior A might handle returns; Junior B might not. They shouldn't have to be different roles for this to work.
Two ways to model this:
- Roles + per-staff exceptions — keep role defaults, add an exception table that overrides for specific staff
- Per-staff permissions only — give every staff a full permission set; no roles. Roles become onboarding presets.
The first is more flexible (a staff change doesn't lose their custom config; they pick up the new role's defaults for un-customized fields). The second is simpler but loses the "role-wide policy change" feature.
Decision
Implement role defaults + per-staff overrides:
roles(id, name)— Sys Admin, Owner, Sales, Mechanic, Junior, Service Lead, etc.role_permissions(role_id, screen_id, can_see)— role defaultsstaff_screen_permissions(staff_id, screen_id, can_see)— per-staff overrides; NULL or missing means "use role default"- Resolved permission:
COALESCE(staff_override, role_default)
UI: the Staff & Permissions card shows the resolved view. Toggling a checkbox writes to staff_screen_permissions (per-staff), not role_permissions (per-role). An explicit "Reset to role defaults" button clears all override rows for that staff.
A separate UI (not yet built) for editing role defaults exists for when the owner wants to change defaults for everyone with that role.
Consequences
Positive:
- Matches the operator's mental model: "I'm configuring this staff member's tabs"
- Roles still exist for fast onboarding (new Junior gets sensible defaults)
- Reset-to-defaults gives an out from per-staff customization gone weird
- Audit log clearly shows whether a change affected one staff or a role
Negative:
- Two tables to consult on every permission resolve
- The "change a role default" path needs to handle the "what about staff with overrides" question — the answer is "their overrides win"; the role-default change only affects un-customized staff
- More moving parts than pure-role
Mitigations:
- The resolution is a single JOIN with COALESCE — cheap
- Documented in the UI: each staff card shows which permissions are "role default" vs "overridden"
- Owner can reset any individual staff to role defaults at any time
Notes
This decision was reactive — the first implementation got it wrong. Captured here because the wrong-way pattern is tempting and the right-way pattern needs to be remembered.