The permission guards are RED on `main` — an inline object type in a parameter hides the gate, and one route is missing from the inventory
pnpm test tests/permissions/ fails three assertions on origin/main at 9aed45b0 — verified by running it, 2026-08-06, not inferred:
× names every app/api route → /api/ai/coding-convention/audit-coverage
× every gated operation is one the inventory also knows about → the same route
× pins how many operations still await MOTIR-2256 → expected 95 to be 94
Two independent causes, both from the audit-coverage work merging alongside the permission model — each branch green against its own base, red together.
Cause 1 — a route the map never saw. app/api/ai/coding-convention/audit-coverage/route.ts landed in e4a6bb53; the inventory was written against the tree before it. It is gated (it calls auditCoverageService.getCoverage, which asserts), so it needs a row, not a gate.
Cause 2 — and this one matters far more than a red build. serviceBodies() in tests/permissions/noUngovernedOperation.test.ts extracts a service method's body by taking src.indexOf('{', …) from just after the method NAME. A parameter carrying an inline object type gets there first:
async reaudit(
projectId: string,
ctx: …,
identifier: string,
options: { repoKeys?: string[] }, // ← the walk thinks THIS is the body
): Promise<…> {
await projectAccessService.assertCanManage(projectId, ctx); // ← never seen
So the walk reports a gated method as ungated, and every route reaching a gate only through such a method is counted as an ungoverned operation. e4a6bb53 added exactly that parameter to reaudit, which is why the pinned count moved by one — but the bug is not one route. Correcting the extractor locally (walk the parameter list to its closing ) first, then find the body's {) moves the guard's own numbers from 52 gated / 95 pending / 38 unconfirmed to 76 gated / 81 pending / 33 unconfirmed. Twenty-four operations the model story reported as ungoverned already have a gate.
That is the reason this is highest and first: every sibling card under this story is sized and closed against those counts, and a walk that under-reports gates in the alarming direction would have had them adding a second assertPermission to code that already asserts.
This card changes NO product behaviour. It touches two test files, the inventory document and two comment blocks. It adds no gate, removes no gate, and moves no permission — a diff here that touches lib/services/** or app/api/** handler logic is out of scope and belongs to the domain card for that area.
Acceptance criteria
serviceBodies()finds a method's real body: it advances past the parameter list (paren-depth to the closing)) before locating the opening{. The memoisedmethodIsGatedcache and the fresh-regex-per-call discipline the file already documents are preserved.- The existing
the guard can actually failblock gains a control for this exact shape: a fixture method whose onlyassertCan*sits after a parameter with an inline object type is reported gated, and a method with no gate at all is still reported ungated. Without both halves the fix is indistinguishable from making the walk answertruemore often. methodIsGated('aiConventionService.reaudit')istrue. It isfalsetoday, and that single expectation is the whole bug in one line.docs/decisions/permission-inventory.mdcarries a row for/api/ai/coding-convention/audit-coveragein itsaisection, with a permission, a decision and a reason that resolves in the Reasons list —ai:plan/new/R5, matching its three siblings. Note in the row's gate-today cell that it is gated viaauditCoverageService.getCoverage. (Whetherai:planis the right key for a route that is admin-gated today is a LOOSENING question, and it belongs to MOTIR-2291 along with its three siblings — do not re-point any gate here.)- Both pinned numbers are re-derived from the run on this branch, not copied from this card: the
PENDINGpin and theCLAIMED_BUT_UNVERIFIEDpin are set to what the corrected walk produces, and the commit message states both before/after pairs. (Expected 95→81 and 38→33 measured at9aed45b0; if the branch produces different numbers, the run wins and the PR body says why.) - The comments that assign the whole
plannedlist to one story are corrected in both places they appear —lib/permissions/catalog.ts(theenforcementdoc block andPLANNED_PERMISSIONS) and the guard'sPENDINGblock — so they name MOTIR-2256 for the twelve administrative keys, MOTIR-2291 for the other eight, andPENDINGreaching zero as the two stories' joint definition of done. pnpm test tests/permissions/is green, andpnpm lint+ 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— the walk,serviceBodies(), both pins, and thecan actually failcontrols.tests/permissions/inventoryCoverage.test.ts— the route↔document totality assertion that fails on the missing row.docs/decisions/permission-inventory.md— the map; itsaisection holds the threecoding-conventionsibling rows to match.lib/services/aiConventionService.ts—reaudit,getAudit,getConvention; each assertsassertCanManage, andreaudit's fourth parameter is the shape that reproduces the bug.app/api/ai/coding-convention/audit-coverage/route.ts— the route with no inventory row.- The guard that this card repairs · the inventory it is pinned to · the story that takes the other eight keys.