`workflow:manage` + `automation:manage` — the statuses a column projects, the automation rules, and the status-derivation switches
⚠️ Re-scoped 2026-08-06 against the branch — the workflow half of this card was BACKWARDS
The card was written from the guard's output. It claimed the workflow Server Actions have "no gate at all" and that /api/board/columns/[columnId]/statuses reaches boardsService.mapStatusToColumn "with only a session", concluding that "today any signed-in workspace member can delete a workflow status." Read on the branch, that is false and the opposite is true.
| Path | Real gate today | Admits |
|---|---|---|
every workflowsService status / transition write | assertProjectAdmin (workflowsService.ts:47) | isOwnerRole(...) — workspace OWNER only |
boardsService.mapStatusToColumn / unmapStatus | assertBoardConfigAdmin (boardsService.ts:1138) | isOwnerRole(...) — workspace OWNER only |
automationRulesService / projectStatusAutomationService writes | projectAccessService.assertCanManage | project:administer — unchanged by the split |
The Server Actions are transport-only, exactly as their own header says; the gate is one layer down, and it is TIGHTER than project:administer. The guard could not see either helper because its GATE pattern is a whitelist of names — MOTIR-2304 repairs it and this card is blocked_by it.
So this card is TWO different changes, and they must be argued separately:
workflow:manageLOOSENS. Statuses, transitions and column→status mapping move from workspace-OWNER-only to a key a workspace admin and a project admin also hold. That is a real grant to real actors. It is also required: MOTIR-2293 already putworkflow:manageinBUILTIN_ROLE_PERMISSIONS.admin, so leaving the gate where it is would have the catalog advertise a permission the code refuses — the lie the model exists to prevent.automation:manageis NEUTRAL. Both automation services already askproject:administer, so re-pointing them changes nothing for anyone. The card's original text is right about this half.
The catalog's workflow domain holds two keys and they are wired together because they are one settings page and one mental model — how work moves through this project.
workflow:manage — the statuses themselves and the columns they project into: workflowsService's status / transition writes (driven by app/(authed)/settings/project/workflow/actions.ts, which is transport-only) and boardsService.mapStatusToColumn / unmapStatus. A workflow status is a project-wide contract — every work item's state, every board column, every automation rule and every ready-set computation reads it — and today only the workspace OWNER may touch one, which is why a project admin cannot set up their own project's workflow.
automation:manage — automationRulesService (rules CRUD, enable/disable, executions) and projectStatusAutomationService (the parent-rollup and child-cascade switches). These are the closest to a pure re-pointing in the story: projectStatusAutomationService already asserts assertCanManage in three places, and the automation routes are the ones the settings nav already gates on manage.
The automation nav entry is the one exception to the story's hands-off rule for projectSettingsNav.ts. It is the only entry whose access predicate is manage rather than browse. Since Admin holds automation:manage exactly when it holds project:administer, moving that entry is behaviour-neutral — but the nav's SettingsNavCapabilities shape is a client-side contract that MOTIR-2258 is about to redesign around a resolved permission SET. So leave the nav entry alone and record why: changing one predicate now buys nothing and collides with that story. The page's own server guard is a different matter and is in scope.
Acceptance criteria
- Enumerate first, against the REPAIRED guard (MOTIR-2304 is
blocked_by): list itsworkflow:manageandautomation:manageoperations, andgit grep -n assertCanManage -- lib/services/automationRulesService.ts lib/services/projectStatusAutomationService.ts lib/services/workflowsService.ts app/\\(authed\\)/settings/project/workflow. The PR body carries operation / gate today / gate after. - Every status and transition WRITE in
workflowsServiceassertsworkflow:managethroughprojectAccessService.assertPermission, and the module-privateassertProjectAdminis DELETED — it is one of the private re-implementations of the access policy this story exists to end, and it is the one that decides a DIFFERENT answer. The Server Actions stay transport-only, matching their own header; a comment there records that the gate is one layer down, so a future reader does not re-add a second one. NotProjectAdminErrorfromlib/workflows/errors.ts:git grep -n "NOT_PROJECT_ADMIN" -- app lib components tests e2eFIRST —app/(authed)/settings/project/workflow/actions.tsandtests/components/components-settings-editor.test.tsxread that string, and MOTIR-2293 kept it reaching them deliberately. Say in the PR what each consumer receives after; aworkflow:managerefusal isPERMISSION_DENIED, so either those consumers are updated or the compatibility is preserved explicitly.- The WIDENING is asserted, not just performed. A test proves the new actor set: a project admin can now create and delete a status and map a column (they could not before this card); a workspace admin can too; a project member and viewer are refused 403. The project-admin case is the one that changes, so it is the one that must be named.
boardsService.mapStatusToColumnand its unmap sibling assertworkflow:manage— the mapping is a workflow decision surfaced on a board, and the inventory's R10 says so. This is the onlyboardsServicemethod this card touches; the rest is the board card's.- Every write in
automationRulesServiceandprojectStatusAutomationServiceassertsautomation:manage, replacing theirassertCanManagecalls; the READS (list,executions, the settings GET) keep the posture they have today rather than gaining an administrative gate. lib/settings/projectSettingsNav.tsis NOT modified. The server guard onapp/(authed)/settings/project/automation/**/page.tsxmoves toautomation:manage; a comment there records that the nav predicate deliberately still readscanManageuntil MOTIR-2258.- Both keys flip to
enforcement: 'enforced'inlib/permissions/catalog.ts; the inventory'sworkflowrows carry their corrected gates and movenew→existing. - Tests: a project admin creates and deletes a status, writes a rule and flips a derivation switch; a project member is refused 403 on each; a member can still TRANSITION a work item through existing statuses (the regression this card could cause); a non-browser gets 404.
- The
automationhalf asserts NEUTRALITY rather than a grant: the actors who could write a rule before can write one after, and no one else can. - The guard's pending pin is re-derived by running the suite on the branch.
pnpm testgreen;pnpm lintand the prettier check pass repo-wide.
Context refs
lib/services/workflowsService.ts— the statuses and transitions, andassertProjectAdmin(line ~47), the workspace-OWNER-only gate this card replaces.lib/services/automationRulesService.ts·lib/services/projectStatusAutomationService.ts— the rules and the derivation switches; both already callassertCanManage.lib/services/boardsService.ts—mapStatusToColumnonly.app/(authed)/settings/project/workflow/actions.ts—createStatusAction,deleteStatusAction,addTransitionAction; listed with no gate in the inventory's Server-Action table.app/api/board/columns/[columnId]/statuses/route.ts·.../statuses/[statusId]/route.ts·app/api/projects/[key]/automation-rules/**·app/api/projects/[key]/status-automation/route.ts.lib/settings/projectSettingsNav.ts— theautomationentry, for reading, not editing.docs/decisions/permission-inventory.md— theworkflowsection, reasons R10 and R28.- The seam this calls · the board card.