approveAccessRequest failed 7 times for ONE admin, 5 of them the same day - the attach gate fails closed with no operator-facing reason
MEASURED 2026-08-22, prod appEvents 30d: 7 admin.error toasts, all action=approveAccessRequest, all role=administrador, ALL from the same actorUserId (968a7cd4) - 2026-08-01, 08-05, then FIVE on 08-07. Same window carries exactly one level=error row: approveAccessRequestAttachFailed. So an administrador tried to grant access five times in one day and it refused five times, and the toast told them only 'admin.error'. Known mechanism (see memory reference_access_request_attach_role_gate): the identity ATTACH fails closed unless the target user has no roles or ALL its roles are already granted - which is correct security behaviour and must NOT be relaxed to silence this. The defect is that a gate-correct refusal is indistinguishable from a fault to the operator, so the only available response is to retry. Fix is operator-facing: name the refusing condition in the toast (which role blocks the attach) and stop the retry loop. Do NOT widen the gate. Pairs with VENUS-389 - same generic-code defect, different surface.
Activity
-
PREMISE FALSIFIED, and the real defect is bigger than the toast. (1) The reason WAS on screen and WAS recorded: recordActionErrorToast writes message as a TOP-LEVEL appEvents column and puts only {role,action,mechanism,code} in detail - a detail-only query reads as "generic admin.error" and that is what this row was filed on. All 7 error-shown rows carry a specific Spanish sentence in appEvents.message, and access-request-review.tsx renders it (setError :220 -> errorBanner :302 mounted :371/424/489/549/740). "No operator-facing reason" is false for 6 of 7. (2) The 7th, "No pudimos vincular la cuenta." 2026-08-07 14:33, was the VENUS-365 SELECT "id" FROM alumnos throw - fixed by cbf8ea3 at 14:38, five minutes later, and hardened by 476447e. Zero recurrence in the 35 days since. (3) THE SURVIVING DEFECT: the attach role gate (auth-actions.ts:1156) says "cambiar el rol es una accion separada - resolvelo antes de vincular", and THAT SEPARATE ACTION HAS NO SURFACE IN THE APP for this role. The only two revoke paths in src are deactivateAlumno (admin-actions.ts:206, gated on an alumnos row) and deactivateTitular (:1245, role=titular literal). Neither can revoke bedelia or administrador. userRoles_userId_unique_active_idx is UNIQUE(userId) WHERE deletedAt IS NULL - exactly ONE live role per user - so there is no add-a-second-role either. Measured: accessRequests b0bb4d5d role=titular status=approved decidedAt 2026-08-07 15:10:40; userRoles for aa3638ea has exactly one row, bedelia, createdAt 2026-07-05, deletedAt NULL. So no role change ever happened. The exit the admin found after 7 attempts over 8 days (two 4 seconds apart) was to re-select the role the account ALREADY HELD - the one value that passes the gate - and neither refusal string names it. The create-collision message (:1439) says "usa Vincular" and Vincular says "resolve the role first": two gates pointing at each other with the working exit unnamed and the named exit unbuildable. (4) POLICY RESIDUE, not code, for pmmaster/Elazar: the request row now reads approved with role=titular while the account holds only bedelia. Attach is correct never to change permissions, but nothing surfaces that mismatch to anyone. Gate stays as-is - it is gate-correct and must not be relaxed.
-
completed
-
SHIPPED bb09470, v1.17.13 LIVE and verified: curl /api/health returns version 1.17.13 dbOk true deploymentId dpl_FVpwXNSstMWfcHWKi242LBuoQPmt at 2026-09-11T14:54:34Z. Gate passed (lint 0 errors / 5 pre-existing warnings, tsc 0, full test suite, next build compiled). 4 files, 187 insertions. What shipped: src/lib/attach-role-outcome.ts NEW (server-only-free: attachRoleVerdict + attachRoleRefusalMessage), src/lib/auth-actions.ts now CALLS it instead of re-implementing the comparison inline, src/lib/__tests__/attach-role-outcome.test.ts 13 tests. COVERAGE STATEMENT, not a clean bill. VERIFIED: the string the gate returns and the compatibility decision behind it, under unit test with a must-fail control - restoring the pre-fix wording fails exactly 4 of 13 (does-not-change-permissions, names-the-working-exit, does-not-say-resolve-it-first, describes-the-change-truthfully) and passes the other 9, so the suite measures the WORDING and not merely the presence of a string. NOT EXERCISED: the gate has not been driven end-to-end through approveAccessRequest, because a server action cannot be driven headless here - server-only kills the import and cookies() kills the gate - and there has been no live attach refusal since 2026-08-07 to observe. The transaction-side behaviour around it (ROLLBACK on refusal, the row lock, reconcileRequestAnchor) is unchanged by this commit and was NOT re-verified. TWO THINGS DELIBERATELY LEFT OPEN, neither mine to decide, both for Elazar: (1) there is NO revoke surface in the app for bedelia or administrador at all - deactivateAlumno needs an active alumnos row, deactivateTitular is a role=titular literal - so a genuine role change for those roles still cannot be performed by any admin through any screen. The new message is TRUTHFUL about that rather than fixing it; building that surface is new scope on live clinical auth that nobody asked for. (2) request b0bb4d5d reads approved with role=titular while the account holds only bedelia, and nothing anywhere surfaces that mismatch to a human.