The guard's gate pattern is a NAME WHITELIST — three privately-named gates are invisible, and three domain cards are sized against the opposite of the truth
MOTIR-2292 fixed the guard's BODY EXTRACTOR and left its GATE PATTERN alone. That pattern is a whitelist of NAMES:
const GATE = /assertCan[A-Za-z]+|get[A-Za-z]*Capabilities|hasPermission\(|canManageProject\(|canBrowse\(|canEdit\(/;
A gate whose function is named anything else is invisible to it. Read on the branch, lib/services holds three such gates, and every operation behind them is reported UNGOVERNED:
| Helper | File | What it actually decides |
|---|---|---|
assertBoardConfigAdmin | boardsService.ts:1138 | isOwnerRole(workspaceMembership.role) — workspace OWNER only |
assertProjectAdmin | workflowsService.ts:47 | isOwnerRole(...) — workspace OWNER only |
assertEstimationAdmin | estimationService.ts:351 | isOwnerRole(...) — workspace OWNER only |
(sprintsService.assertSprintAdmin is a fourth, same shape, but its domain is MOTIR-2291's, not this story's.)
This is the MOTIR-2292 failure recurring one level up, and it is worse this time. There, the instrument under-reported gates and would have sent cards adding a second assertPermission in front of code that already asserted. Here it under-reports gates AND misdescribes their POLICY — so three cards under this story were written claiming "no project gate at all today… any signed-in workspace member can add or delete a column on any board they can reach… closing that is most of this card's value." The truth is the exact opposite: those operations are gated more tightly than project:administer. Re-pointing them to the new keys is a LOOSENING — a project admin and a workspace admin GAIN board / workflow / estimation configuration they cannot perform today.
And that loosening is now REQUIRED, not optional. MOTIR-2293 already put board:configure, workflow:manage and estimation:manage into BUILTIN_ROLE_PERMISSIONS.admin. If the gates stay workspace-owner-only, the catalog says a project admin holds those keys while the code refuses them — precisely the lie lib/permissions/catalog.ts's opening rule exists to prevent. So the split must widen them, and the domain cards must SAY SO instead of claiming neutrality.
Acceptance criteria
- The
GATEpattern stops being a name whitelist overlib/services:serviceBodies()'s walk resolves a call to a MODULE-LOCAL function in the same file and tests that function's body too, so a gate reached throughassertBoardConfigAdmin/assertProjectAdmin/assertEstimationAdmin— or any future private helper — is seen. The existing cross-SERVICE hop (serviceCalls+methodIsGated) is unchanged; this adds the same-file hop it never had. - The
the guard can actually failblock gains a control for THIS shape, both halves: a fixture method whose only authorization is a call to a module-localassertSomethingAdminthat itself reads a membership role reports gated, and a fixture method calling a module-local helper that asserts NOTHING still reports ungated. Without both halves the fix is indistinguishable from making the walk answertruemore often — the same control MOTIR-2292 owed and paid. docs/decisions/permission-inventory.mdgains a GATE TODAY, MEASURED subsection recording, per administrative domain, the gate that actually runs and the actor set it admits:board/workflow/estimation= workspace OWNER only;automation/component/field/ai=project:administer-equivalent;label/repository=assertCanEdit(project member). Theboardsection's six rows and theworkflow,field,estimationsections' rows have theirGate todaycells corrected to name the real helper — they currently readsession only, which is false.- The document records that
project:administeris NOT the tightest administrative gate in the product today, so this story's split both TIGHTENS some domains and LOOSENS others, and the per-domain card is where each is argued. - Both pinned counts are re-derived from the run on this branch and the commit message states the before/after pair. The
PENDINGcount is expected to FALL (operations that were never ungoverned stop being counted) — a fall here is the instrument being corrected, not gates being added. This card adds no gate, removes no gate, and changes no route. - A diff that touches
lib/services/**orapp/api/**handler logic is out of scope and belongs to the domain card for that area. pnpm testgreen;pnpm lintand the prettier check pass repo-wide (not over a scoped glob — a path with a route group in it matches nothing and exits 0).
Context refs
tests/permissions/noUngovernedOperation.test.ts—GATE,serviceBodies(),methodIsGated, both pins, and thecan actually failcontrols.lib/services/boardsService.ts:1138·lib/services/workflowsService.ts:47·lib/services/estimationService.ts:351— the three invisible gates.lib/services/componentsService.ts:109·lib/services/customFieldsService.ts:141— two MORE private copies of the policy, visible to the guard only because they happen to be namedassertCanManage.docs/decisions/permission-inventory.md— theboard,workflow,fieldandestimationsections.- The guard-repair card this recurs from · the seam the domains re-point to.