basquetWi + New ticket
pluto PLUTO-511

Prácticas: sometimes a student is operador with NO student asistente (single-participant práctica) - define canonical rule

Done normal cpcoder-pluto-cc

Surfaced by the Matías Wang case: Dayeli (operador) confirmed no other student participated - i.e. a práctica can exist with an operador but no asistente. Elazar flags this as a distinct data/workflow problem: is an asistente required? What is the canonical participant rule for a real clinical práctica (operador-only allowed vs operador+asistente mandatory)? Affects create-time validation + how single-participant prácticas are counted in stats/distribution (PLUTO-507). NEEDS A BIGGER TALK WITH CLIENT to set the rule before any enforcement. Related: PLUTO-78, PLUTO-507. Blocked on client discussion.

Sub-tickets

No sub-tickets.
+ Add sub-ticket

Activity

  • wi-cli-venus created · 2026-07-04
  • wi-cli-venus blocked · 2026-07-04
    Needs bigger talk with client to set the canonical single-participant-practica rule before impl (Elazar 2026-07-04)
  • wi-cli-venus assigned · 5w ago
    coder-pluto-cc
  • wi-cli-venus assigned · 5w ago
    coder-pluto-cc
  • wi-cli-venus unblocked · 5w ago
    RULED AND UNBLOCKED by pmmaster-evolutiva-cc on Elazar's behalf, 2026-09-11, under his 09:27 grant. The blocker read 'needs bigger talk with client' and was set 2026-07-04 - it has held for nine weeks and the talk has not happened, while the situation it describes keeps occurring in production data. CANONICAL RULE: a single-participant practica is VALID. One student as operador with no student asistente is recorded as-is, asistente NULL, and nothing rejects it. Rationale, and it is not a preference: the shape already exists in the data, so the only choice available is whether it is recorded honestly or forced into a false second participant. Rejecting it would push the user to name someone who was not there, which is the same defect as TERRA-149 - one person asserting another attended a surgery - and it corrupts the attendance record to satisfy a form. A NULL asistente is a fact; a fabricated one is not recoverable afterwards. WHAT THIS RULING DOES NOT COVER, deliberately: it does not decide whether a single-participant practica counts the same as a two-participant one for any informe, credit or estadistica. That is a catedra question and stays open - but it is a DOWNSTREAM question that cannot even be asked until the shape is recordable, so it does not block this. If the catedra later says single-participant practicas do not count, that is a predicate on a column that will by then exist. Elazar can reverse this on one row.
  • wi-cli-venus statusChanged · 5w ago
    ANALYSIS RECORDED 2026-09-11, NOT YET BUILT - read-complete, no edits. Deliberately not folded into the PLUTO-510 push: 510 ADDS a rejection and 511 REMOVES one, on the same write path, and they belong in separate commits. THE STUDENT PATH ALREADY CONFORMS to the ruling, via PLUTO-528: asistenteId is nullable, isSinAsistente is the one canonical predicate, the classmate gate is skipped when there is no companion, and the duplicate pre-check has a solo arm keying on `operadorId = $3 AND asistenteId IS NULL`. A student logging solo is recorded as-is today. Nothing to do there. THE GAP IS THE TEACHER PATH, and the mechanism is worse than a missing branch. src/lib/practica-actions.ts:104-106: const isTeacherMode = !user.roles.includes("alumno") && user.roles.some((r) => TEACHER_MODE_ROLES.has(r)) && !!data.operadorId && !!data.asistenteId; asistenteId PRESENCE is part of the MODE TEST. So a docente submitting without an asistente is not rejected - the payload FALLS THROUGH to the student branch, where `asistenteId = data.rol === "operador" ? (data.companeroId || null) : currentUserId`. With rol=asistente that writes the DOCENTE OWN ID into a participant column. That is the fabricated-participant class this ruling exists to prevent ("a NULL asistente is a fact; a fabricated one is not recoverable afterwards"), arrived at by a different route than the one the row describes. A discriminator that doubles as a validation cannot be relaxed by deleting a check - splitting it is the work, and every isTeacherMode branch downstream has to be re-read, including the teacher-mode scopeCheck at ~L246 whose `asistenteId!` non-null assertion is justified in-comment by precisely the conflation being removed. CLIENT HALF: drop the `fail("Seleccioná el asistente", "practica.validate.asistente.required")` at src/components/practica-form.tsx:393-397. The friction code is registered at src/lib/friction-codes.ts:228 and practica-form.tsx:395 is its ONLY emitter, so removing the emitter leaves a dead registry entry - decide whether it goes too (the registry is a trust boundary for recordFriction; an unused entry is harmless but is drift). ONE READ STILL OWED before touching code, and it must be read rather than inferred: for a teacher payload that falls through today, does operadorId end up "" / undefined and die loudly on a uuid parse (in which case this is a 511 concern only), or does it WRITE (in which case the fall-through is its own p1 and does not wait for 511)? Do not assume the loud answer. NOT STARTED for budget reasons: the fleet cap window closes ~16:15-16:30Z and this is not a one-gate-round change. Nothing is blocked - the row is ready to pick up.
  • wi-cli-venus note · 5w ago
    THE ONE READ THIS ROW OWED IS DONE, AND THE LOUD ANSWER IS WRONG. The question was: for a teacher payload that falls through the isTeacherMode discriminator today, does operadorId end up ""/undefined and die loudly on a uuid parse (a 511-only concern), or does it WRITE? IT WRITES. Traced, not inferred: practica-form.tsx builds companeroId as "" whenever teacherMode is true (L492) and only then attaches operadorId/asistenteId (L515-517); rol is useState<"operador"|"asistente">("operador") at L203 and the rol radio renders only in the student block, so a teacher payload always carries rol="operador". In practica-actions.ts the discriminator at L104-106 requires !!data.asistenteId, so a teacher payload with a missing asistente is NOT teacher mode; it falls into the student arm at L139-141, where operadorId = data.rol === "operador" ? currentUserId : data.companeroId evaluates to currentUserId - THE DOCENTE - and asistenteId = data.companeroId || null evaluates to "" || null = NULL. isSinAsistente is then true, so the PLUTO-510 compañero gate is skipped entirely (correctly - there is no compañero) and nothing downstream objects. THE ROW IS INSERTED. Consequences, in order of severity: (1) the operador the docente actually SELECTED - a real student, present in data.operadorId - is silently DISCARDED, and the práctica is attributed to the docente instead. That is a mis-attributed clinical record, not a missing one. (2) It presents as a legitimate PLUTO-528 "Sin asistente" solo práctica, so it is indistinguishable after the fact from a student who genuinely worked alone. (3) There is no uuid parse to die on, so there is no loud failure and no appEvents row: this is a silent-write class, not a crash class. WHAT IS HOLDING IT SHUT TODAY IS THE CLIENT VALIDATION AT practica-form.tsx L393-397, fail("Seleccioná el asistente", "practica.validate.asistente.required") - which is exactly the check this row is scheduled to DELETE. That makes the ordering load-bearing and it is now a hard constraint on 511: the client-side removal MUST NOT land before the server-side mode split, or a client-only guard is removed from a "use server" export that is directly invocable without the form at all. SO THE FALL-THROUGH DOES NOT WAIT FOR 511 in principle - it is its own defect and a write-path one - but in practice the fix is the same edit: split the discriminator so mode is decided by ROLE alone and a teacher payload missing its asistente is REJECTED in the teacher arm rather than re-routed into the student arm. Doing it as one change is correct; doing the deletion half first is not. Not started for budget reasons (fleet cap window closes ~16:15-16:30Z); nothing is blocked and no code was touched for this note.
  • wi-cli-venus note · 5w ago
    Built, not yet pushed (holding for venus-host.push-gate; coder02-mars-cc held it at 16:42). Server: practica-actions.ts isTeacherMode is now ROLE-ONLY (the !!data.operadorId && !!data.asistenteId conjuncts folded a payload validation into a mode discriminator, which is what silently re-routed a teacher payload into the student branch and wrote the docente into the operador column); a pre-connect guard rejects a teacher payload with no operadorId; the teacher arm resolves asistenteId = data.asistenteId || null; the scopeCheck asistenteId! is gone, replaced by a null-aware test, because IN ($2,$3) with a NULL $3 returns the operador row alone and a one-row result there is CORRECT, not a miss. Duplicate pre-check and the TIER-2 swap branch needed NO change - they already fork on isSinAsistente. Client: the practica.validate.asistente.required hard-stop is deleted, the teacher asistente select loses required and its asterisk and gains the Sin asistente sentinel item, the sentinel collapses to "" in the payload, and the PLUTO-571 soft-confirm is EXTENDED to teacher mode with a teacher-voiced copy fork - same accident, same guard; removing the hard stop without extending the confirm would have left that half unguarded. friction-codes.ts: practica.validate.asistente.required removed - its only emitter was practica-form.tsx:395 and KNOWN_MESSAGE_CODES is the live-call-site set ("Adding a call site = adding its literal here"), not a retired-but-accepted bucket, so an entry with zero emitters is drift on a trust boundary. Coverage: src/lib/practica-teacher-sin-asistente.test.ts, 6 arms. ORDERING: the client deletion and the server split ship as ONE commit deliberately - the client check was the only thing holding the silent-write shut.
  • wi-cli-venus completed · 5w ago
    SHIPPED dc017c5, live 2.23.95 (/api/health version 2.23.95, dbOk true, dpl_9rpB58JWuWesoBTMq6b6Xi5nx4Bk) - pushed AND working, not merely pushed. isTeacherMode is ROLE-ONLY; a pre-connect guard rejects a teacher payload with no operadorId; the teacher arm resolves asistenteId = data.asistenteId || null; the scopeCheck asistenteId! is replaced by a null-aware test because IN ($2,$3) with a NULL $3 returns the operador row alone and a one-row result there is CORRECT. Client: the asistente hard-stop deleted, the teacher select loses required+asterisk and gains the Sin asistente sentinel, the sentinel collapses to "" in the payload, and the PLUTO-571 soft-confirm is EXTENDED to teacher mode with a teacher-voiced copy fork. friction-codes.ts: practica.validate.asistente.required removed with its only emitter. 6 new arms in src/lib/practica-teacher-sin-asistente.test.ts, 932 tests green in the pre-push gate, mutation-checked (restore && !!data.asistenteId -> 4 of 6 fail, the 2 paired arms hold). NOT EXERCISED: pg is mocked so the IN-list NULL semantics are asserted in a comment and fed to the mock, never observed in Postgres; the picker, the sentinel collapse and the confirm dialog have no test; nothing was run against prod data. FOLLOW-UPS ON THE RECORD: PLUTO-739 (scopeCheck asserts roster membership not role) still open and needs the product rule first; and the two isTeacherMode predicates are DUPLICATED LITERALS - server reads TEACHER_MODE_ROLES, src/app/(protected)/practicas/nueva/page.tsx:27-29 hard-codes the same five names - verified set-identical today and pinned in a comment, because any drift now routes a user into the student form while the server demands the teacher payload.
task
2026-07-04 by wi-cli-venus
5w ago
2026-09-11 22:48