`sprint:manage` — the sprint lifecycle, the backlog rank and a work item's sprint, with the sprint READS left at `project:browse`
Today a project viewer can start a sprint, complete it, drag the backlog into a new order and move somebody else's work item into the active sprint. backlogService and sprintsService contain no access assertion of any kind on origin/main — the routes reach a gate only through whatever else they happen to call. This card gives the sprint domain its key.
The split this card makes, which the inventory does not
The inventory maps all thirteen sprint rows to sprint:manage. Three of them are READS and must not need it — a viewer who can see the board can see the backlog and the sprint's issue list, exactly as Jira's Manage Sprints governs creating and starting a sprint while Browse Projects governs seeing one. Three more are ANALYTICS and belong to the reports card rather than here, so no two cards flip the same key.
| Operation | Service method | Key |
|---|---|---|
POST /api/sprints, PATCH/DELETE /api/sprints/[id], /start, /complete | sprintsService.createSprint / updateSprint / deleteSprint / startSprint / completeSprint | sprint:manage |
/api/backlog/bulk-move, /api/sprints/[id]/issues/bulk, /api/work-items/[id]/rank, /api/work-items/[id]/sprint | backlogService.bulkMoveToBacklog / bulkAssignToSprint / rankIssue / assignToSprint / moveToBacklog | sprint:manage |
POST /api/backlog (create an issue into the backlog) | backlogService.createBacklogIssue | work_item:edit — it creates a work item; ranking it is the sprint act, authoring it is not |
GET /api/backlog, GET /api/sprints, GET /api/sprints/[id]/issues | backlogService.getBacklog / getSprintIssues, sprintsService.listByProject | project:browse |
/api/sprints/[id]/burndown, /report, /points | reportsService.getSprintCycleGraph, sprintsService.getSprintReport, estimationService.rollupForSprint | report:view — NOT this card; owned by the reports card so one key has one owner |
Gates go in the SERVICE, not the route (the 4-layer contract), and every one is projectAccessService.assertPermission(projectId, ctx, key) — the shipped seam, no new assert method.
Acceptance criteria
- Every write listed above asserts
sprint:managebefore it reads or writes a row, and the assertion is inside the service method, not the route handler. - Each of the three reads asserts
project:browse, andcreateBacklogIssueassertswork_item:edit. sprint:manageflips toenforcement: 'enforced'inlib/permissions/catalog.ts, andtests/permissions/catalog.test.ts's enforced-key list and length pin are extended by exactly this key.- The inventory rows for the ten operations this card owns record the key each one landed on, and the three analytics rows are left for the reports card with a note naming it.
- A unit test per service drives a
viewerinto the typed refusal on a write and through on a read, using the real resolution rather than a mockedhasPermission. - An integration test proves a workspace member with NO project membership cannot start a sprint on an
openproject, since that actor resolves throughIMPLICIT_WORKSPACE_MEMBER_PERMISSIONSrather than a role. - The guard's pinned
PENDINGcount is re-derived by runningtests/permissions/noUngovernedOperation.test.tson this branch, and the drop equals the ungated rows this card gated. pnpm test tests/permissions/ tests/services/is green.
Context refs
lib/services/backlogService.ts,lib/services/sprintsService.ts— the two services with no assertion onorigin/main.lib/services/projectAccessService.ts—assertPermission, and theProjectNotFoundError-before-PermissionDeniedErrorordering every gate inherits.docs/decisions/permission-inventory.md— the thirteenR14rows.docs/decisions/member-facing-permissions.md— which roles holdsprint:manage(written by the decision).- The role-set seam — the card that makes this key resolve to anybody.
board:configure— the sibling one story over that did this shape for the board, including what it deliberately did NOT take (the card drag stayedwork_item:edit).