Prácticas: enforce operador/asistente role separation once rosters are curated (students can't pick a docente as asistente)
Surfaced by the Matías Wang case (SOP-4): a docente_ayudante_primera was recorded as a student asistente on a práctica. Root issue (Elazar): the real fix is NOT that he was mis-picked - it's that once rosters are curated (docentes are docentes, students are students), the práctica participant picker must ENFORCE role separation so a student cannot select a docente as asistente/operador, and only students appear as selectable participants. Design the enforcement: constrain the operador/asistente selectable set to active students in the relevant comisión roster; reject/hide docente-role users. NEEDS A BIGGER TALK WITH CLIENT before implementing (defines the exact role/roster rules). Related: PLUTO-78 (duplicate practicas), PLUTO-508. Blocked on client discussion.
Activity
-
Needs bigger talk with client to define role/roster enforcement rules before impl (Elazar 2026-07-04)
-
coder-pluto-cc
-
coder-pluto-cc
-
NARROWED AND UNBLOCKED by pmmaster-evolutiva-cc on Elazar's behalf, 2026-09-11. The blocker 'needs bigger talk with client to define role/roster enforcement rules' was set 2026-07-04 and has held nine weeks. The talk is about the GENERAL framework; this row's own title states a specific rule that needs no framework: a student cannot pick a docente as asistente. Implement exactly that and nothing wider. IN SCOPE: when the operador is a student, the asistente selector excludes docentes, and the server action rejects the combination independently of the UI - the gate lives on the write path, not only in the picker, or it is decoration. OUT OF SCOPE and still genuinely the client's: roster curation itself, any rule about which docentes may supervise which comisiones, and anything that depends on a curated roster existing. Do not implement those and do not treat this row as covering them. If enforcing the narrow rule turns out to REQUIRE a curated roster to know who is a docente, stop and say so in one line - that would mean the row is genuinely gated and I have the dependency backwards, which is worth one sentence and no more.
-
SHIPPED 455a5fc / v2.23.93, 2026-09-11 15:07 ART. Both halves of the ruling landed: the asistente selector excludes docentes AND the server action rejects the combination independently of the UI. WHAT WAS ACTUALLY WRONG, because the existing check looked like it covered this. Student mode validated the compañero with a studentAssignments sa1 JOIN studentAssignments sa2 on the same comisión+período. That asserts ROSTER MEMBERSHIP, not ROLE. A pure docente holds no studentAssignments row and was therefore ALREADY rejected - which is exactly why the gap stayed invisible. The only shape that got through is a user holding BOTH a docente role AND a student assignment; named as a compañero, that user was recorded in a participant column of a student-logged práctica. NO CURATED ROSTER NEEDED - answering the "stop and say so in one line" clause explicitly: vUserRolesActive (live userRoles joined to the role lookup, already filtered to deletedAt IS NULL, so a revoked role does not keep anyone out) plus TEACHING_ROLES answers "is this person a docente" directly. TEACHING_ROLES and NOT TEACHER_MODE_ROLES - the latter carries administrador, who is not a docente. The role set is passed as a text[] parameter from src/lib/role-constants.ts, not written into the SQL, so the four names keep one definition. WRITE PATH (enforcing): src/lib/practica-actions.ts, the student-mode else-if arm. Membership and role come back as two booleans (isClassmate, isDocente) from ONE EXISTS/EXISTS query, not a merged AND - a merged predicate can only say "inválido", and the point of the rule is to tell a mis-rostered stranger from a docente. Two distinct Spanish messages so the causes stay countable apart; both ride the existing practica.create.save.failed friction code, so the PLUTO-534 registry lint is untouched. One round-trip because a student submit already holds a pooler client inside an open transaction. PICKER (UX only): getClassmates in src/db/queries.ts - one additive NOT EXISTS, which covers both call sites (the /practicas/nueva compañero dropdown and the student-creator candidate list in participant-change-actions.ts). COVERAGE: src/lib/practica-companero-docente.test.ts, 5 arms. The rejection arm runs with isClassmate TRUE, because that is precisely the shape that used to pass. Bounding arms: an ordinary classmate still reaches the duplicate pre-check; a non-classmate still gets its original message; the role question is asserted to ride the SAME query, parameterised from TEACHING_ROLES with administrador asserted ABSENT; a "Sin asistente" solo práctica never runs the companion check at all (PLUTO-528). Mutation-checked by replacing the isDocente branch with false: exactly 1 of 5 fails. 926 tests / 73 files green. NOT EXERCISED, so the claim stays honest: the pg client is mocked - this pins control flow, not Postgres behaviour or the contents of vUserRolesActive. The picker filter has no test. No prod read was taken, so this is NOT a claim that such a práctica exists today; it closes the path, it does not report an incident. FOLLOW-UP, named rather than left as an omission: teacher-mode scopeCheck (practica-actions.ts ~L218) has the SAME roster-not-role hole - same studentAssignments join, no role check. Out of scope here per "nothing wider". Deliberately NOT done: roster curation, docente-comisión supervision rules.
-
TWO CORRECTIONS TO THE SHIPPED COMMIT MESSAGE, recorded here rather than left to be inherited by the next reader. (1) THE COUNTABILITY CLAIM IS FALSE. The commit says the two Spanish rejection strings "stay countable apart in the friction rail". They do not. Friction capture is CODE-KEYED: the client sends a stable code and the persisted message is a short Spanish telemetry LABEL resolved from that code (src/lib/friction-codes.ts lines 5-10 and 328), not the server error string. Both rejections ride the single already-registered practica.create.save.failed, so in the rail they are one bucket and indistinguishable. What is actually true is the half that motivated the two-boolean shape in the first place: the two causes are countable apart TO THE USER, who sees a different toast for "no es valido" (mis-rostered stranger) than for "es docente", and countable apart IN CODE, because the action returns on two distinct branches. If they ever need to be separable in telemetry, that is a second friction code and a registry entry, which this change deliberately did not add. (2) THE GATE FIRES ON BOTH rol ARMS, and that is a decision rather than an oversight. The ruling was worded operador-is-a-student therefore the asistente selector excludes docentes, but the check sits in the shared "else if (!isSinAsistente)" arm, so it also rejects a student logging AS the asistente who names a docente as the operador. Same rule, same picker (getClassmates feeds both), and the narrower reading would have left the mirror-image hole open on a path the same student can reach by flipping one radio button. Nothing wider was added: it is still only about the compañero of a STUDENT-logged practica. VERIFIED LIVE: /api/health reads version 2.23.93, dbOk true, deployment dpl_C3K18eQDZx7d2UHK8812PjsoZJAK - so this is "pushed and working", not merely pushed. The nine "use server export lint FAILED" / "Ecmascript file had an error" lines in the push log are the lint self-test fixtures (src/mod.ts, which does not exist) and swc trace noise on src/lib/email.ts and src/lib/email-admin.ts; the build they appear in is the build that deployed 2.23.93.