`field:manage` + `component:manage` + `label:manage` + `estimation:manage` — the four project vocabularies, with their READS left where they are
⚠️ Re-scoped 2026-08-06 against the branch — "the ungated ones" were not ungated
The card claimed /api/fields/[fieldId], its two options routes and /api/projects/[key]/estimation-config reach their service "with workspace membership alone." Read on the branch, all four are gated; three of them at exactly the right level and one of them TIGHTER than project:administer:
| Vocabulary | Real gate today | Admits | Effect of the split |
|---|---|---|---|
field | customFieldsService's module-private assertCanManage (line ~141) | workspace manager OR project admin | NEUTRAL |
component | componentsService's module-private assertCanManage (line ~109) | workspace manager OR project admin | NEUTRAL |
label | projectAccessService.assertCanManage | project:administer | NEUTRAL |
estimation | assertEstimationAdmin (estimationService.ts:351) | isOwnerRole(...) — workspace OWNER only | LOOSENS |
The guard reported the estimation route ungoverned because its GATE pattern is a whitelist of names and assertEstimationAdmin is not one — MOTIR-2304 repairs it and this card is blocked_by it. The field and component helpers escaped only because they happen to be named assertCanManage; they are two more private copies of the access policy, and this card deletes them.
So three of the four keys are the neutral re-pointing the story describes, and estimation:manage is a deliberate LOOSENING — the estimation scheme moves from workspace-OWNER-only to a key a workspace admin and a project admin also hold. That is required, not optional: MOTIR-2293 already put estimation:manage in BUILTIN_ROLE_PERMISSIONS.admin, so leaving the gate where it is would have the catalog advertise a permission the code refuses.
Four keys, four services, one idea: the vocabularies a project DEFINES. Custom fields, components, labels and the estimation scheme are each a shared list that everyone's work items then draw from, and defining them is administrative. estimation:manage is its own catalog domain and rides here rather than in a card of its own because it is one route and one service — a separate pull request for it would be more overhead than work.
field:manage→customFieldsService(create / rename / reorder / delete a field, and its options)component:manage→componentsServicelabel:manage→projectTagsService(the project's label / tag vocabulary)estimation:manage→estimationService.updateEstimationConfig
Three of the four are a re-pointing at an equivalent gate; estimation is the one that moves — see the re-scope above for the measured gate behind each.
⚠️ The reads are the trap, and getting them wrong breaks the issue editor for every member. GET /api/projects/[key]/fields, /labels, /tags, /components and /estimation-config are how the create/edit form knows what a member may CHOOSE. The inventory maps each of them to the domain's manage key, and applying that literally would make it impossible for a non-admin to set a component or pick a label. Every read in this domain stays where it is — assertCanBrowse, or project:browse under the new vocabulary — and the inventory rows are corrected to say so.
⚠️ And the second trap, one level down: /api/work-items/[id]/labels and /api/work-items/[id]/components are not this card's. APPLYING a label to a work item is work_item:edit; DEFINING the label is label:manage. The inventory already has that right; do not let the file names blur it.
Acceptance criteria
- Enumerate first, against the REPAIRED guard (MOTIR-2304 is
blocked_by): run it for these four keys, andgit grep -n assertCanManage -- lib/services/customFieldsService.ts lib/services/componentsService.ts lib/services/projectTagsService.ts lib/services/estimationService.ts. The PR body carries operation / gate today / gate after for every row in thefieldandestimationsections. - Every vocabulary-DEFINITION write asserts its domain key through
projectAccessService.assertPermission, threadingtxwhere the method already runs inside a transaction. The module-privateassertCanManageincustomFieldsServiceand incomponentsService, andassertEstimationAdmininestimationService, are all DELETED — three more private copies of the access policy, one of which decides a different answer. - The
estimationWIDENING is asserted, not just performed. A test proves it: a project admin can now update the estimation scheme (they could not before this card), a workspace admin can too, and a project member is still refused. The other three keys assert NEUTRALITY instead — the actors who could define a field, a component or a label before can after, and no one else can. - Every vocabulary READ keeps a browse-level gate. The card's tests assert this directly: a project member can still list fields, labels, components and the estimation config, and can still apply a label and a component to a work item.
- The four keys flip to
enforcement: 'enforced'inlib/permissions/catalog.ts. - The inventory's
fieldandestimationsections carry corrected gate-today / gate-after cells; every read row is markedproject:browsewith a one-line reason rather than the domain's manage key, and the rows this card wires movenew→existing. - Tests: an admin defines and deletes a field, an option, a component, a label and updates the estimation scheme; a project member is refused 403 on each; a non-browser gets 404.
- 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/customFieldsService.ts·lib/services/componentsService.ts·lib/services/projectTagsService.ts— each already callsassertCanManageon some paths; the enumeration is which, and which are missing it.lib/services/estimationService.ts—getEstimationConfig(read) andupdateEstimationConfig(write).lib/services/customFieldValuesService.ts— the VALUE writes, which arework_item:editand out of scope; named here so the boundary is legible.app/api/fields/**·app/api/projects/[key]/fields/route.ts·.../labels/route.ts·.../tags/route.ts·.../components/route.ts·.../estimation-config/route.ts.docs/decisions/permission-inventory.md— thefieldandestimationsections; reasons R20, R24, R25, R26.- The seam this calls · the model story.