Practicas teacher-mode scopeCheck asserts roster membership, not role - same hole PLUTO-510 closed on the student path
Found while shipping PLUTO-510 (455a5fc, v2.23.93). src/lib/practica-actions.ts teacher-mode branch (~L218) validates both named participants with a studentAssignments query gated by scopeJtpIds / adjuntoComisionIds / scopeJtpGates. Like the student-mode classmate check PLUTO-510 just fixed, that asserts ROSTER MEMBERSHIP and says nothing about ROLE: a user holding BOTH a docente role AND a studentAssignments row inside the teacher scope passes it and is recorded as operador or asistente of the practica. Deliberately left out of PLUTO-510 - its ruling said "nothing wider", and the two are not the same decision. On the student path the rule is settled (a student practica has student participants). On the teacher path it is NOT obviously a defect: a docente who also carries a student assignment may legitimately be a participant, and rejecting that is a product call, not a bug fix. That is what this row is for - get the rule, then decide whether the same NOT EXISTS over vUserRolesActive / TEACHING_ROLES belongs there. No prod read was taken. This is a code-path finding, not an incident report, and there is no evidence such a practica exists today. The mechanism to reuse is in 455a5fc: two EXISTS booleans from one query, so the two rejection causes stay countable apart.
Activity
-
coder-pluto-cc
-
Blocked on a product ruling, not on implementation - eq#119 to Elazar. The question: in a TEACHER-filed práctica, may a user holding BOTH a docente role AND a studentAssignments row in that teacher's scope be recorded as operador/asistente? Verified in current code (not carried from the row): the teacher-mode scopeCheck in src/lib/practica-actions.ts queries studentAssignments with the scope gates only and names no role predicate, while the student-mode branch does carry the PLUTO-510 isDocente EXISTS. So the premise holds and the mechanism to copy is settled (one query, two EXISTS booleans, distinct messages). What is NOT settled is whether to reject: PLUTO-510's ruling said 'nothing wider' and this row records why - a dual-role participant on the teacher path may be legitimate, so narrowing it is a product decision. Options put to Elazar: A) block, mirror 510. B) allow, close as not-a-defect. C) allow but mark, surface to be named. pmmaster-evolutiva-cc dispatched this as 'the shape is already decided by PLUTO-510'; that is a pre-committed diagnosis the row contradicts, which is why nothing was implemented. No prod read taken - still no evidence such a práctica exists.
-
awaiting Elazar ruling eq#119: may a dual-role docente+roster user be a participant in a teacher-filed práctica
-
eq#119 answered - ruling C, allow but mark: a dual-role docente+roster participant in a teacher-filed practica is ALLOWED and the dual-role fact is RECORDED, with the gate's accept/reject decision unchanged. STATED ASSUMPTION of the ruling: the dual-role case is legitimate and the mark exists to make it countable, not to deter it. The mark is telemetry, NOT a control - nothing reads it to decide anything.
-
SHA e4885ee, package.json 2.24.3 -> 2.24.4, pushed under ~/.cache/evolutiva/pluto-push.lock with --only on exactly four paths. src/lib/practica-actions.ts - the teacher-branch scopeCheck now carries the discriminator as a COLUMN: SELECT DISTINCT sa."studentId", plus an EXISTS over vUserRolesActive filtered by $7::text[] = [...TEACHING_ROLES]. The gate predicate is byte-identical: foundIds / !foundIds.has(operadorId) || (asistenteId !== null && !foundIds.has(asistenteId)) -> ROLLBACK + the same Spanish refusal. One round-trip, one transaction, no second query added. Collection iterates the two SLOTS (operador, asistente) over a Map keyed by studentId, not the returned rows - once-per-participant is a property of the loop, so it holds even if the SQL DISTINCT were removed. Emission moved into the finally, AFTER client.release(), gated on committedPracticaId !== null (hoisted to function scope, set only after COMMIT). Three properties that placement buys and the reasons for each: (1) it cannot park a pooler client while logging - the logger is a separate pool consumer and pool.ts runs max=2 (PLUTO-692); (2) it cannot roll back a committed practica; (3) it cannot replace the return value - a throw in a finally REPLACES the already-evaluated return { success: true }, so both logAuthz and its reporter are swallowed, logCaughtError with .catch(() => {}) on the reporter itself. logAuthz, not logAuthzError: level info, category authz. authz is a SECURITY category, so the row survives LOG_LEVEL gating and gets >=365d retention, while error tier would put a legitimate allowed case on the real-time applog alert rail (PLUTO-519). Payload uses actorUserId, NOT userId - userId is absent from KNOWN_FIELD_KEYS and would have landed silently in detail, leaving the row unattributed; pinned by an assertion. action practicaTeacherParticipantDualRole; fields actorUserId, resourceType practica, resourceId = the committed practica id, slot, participantId, periodoId. src/lib/practica-teacher-dual-role-mark.test.ts - NEW, 10 arms. src/lib/practica-teacher-sin-asistente.test.ts, src/lib/practica-23505-recovery-sql.test.ts - needle updated to the exact new SELECT prefix; the bare column name sa."studentId" also matches an earlier query in the same action and silently returned the wrong call.
-
Class-A PTD, coder-run. What was exercised, and what was NOT. FIRE/NO-FIRE pair, same run: the mark FIRES once for a dual-role operador (asserting actorUserId, resourceId, slot, participantId, periodoId, and .not.toHaveProperty('userId')) and stays SILENT when neither participant holds a teaching role. Also covered: the asistente slot alone, both slots, marks exactly ONCE when the scope query returns duplicate rows, emitted after COMMIT and after RELEASE (calls.indexOf('RELEASE') < the log call index), silent on rollback, returns success when BOTH logAuthz and logCaughtError reject, the gate's decision byte-identical across the docente and non-docente row sets, and params[6] containing the four docente roles and not administrador. FOUR FORCED REDS, each failing only its intended arm(s) - the mutations: (1) drop the isDocente filter from the EXISTS -> 3 arms red; (2) drop the committedPracticaId gate -> the rollback arm red; (3) collect per returned row instead of per slot -> the duplicate-rows arm red; (4) emit before client.release() -> the ordering arm red. ./.gitpush-pre.sh exit 0: 91 files, 1148 tests passing. Audit: audit-general-ca, liveness checked by roster at ask time (poll_age 2s), returned PASS on the working tree with per-file SHA256 prefixes and 'rehash all four before push'. All four rehashed identical immediately before the push (d5929638 / 90dfa4fb / 407b6cd9 / eb8eee98). The verdict belongs to the ledger, not to this commit; the SHA has been sent to audit-general-ca so the ledger carries it against e4885ee rather than against a working tree. DEPLOY: remote package.json on main read via gh api = 2.24.4. Vercel commit status for e4885ee = success, target_url id 22dny79m5tQ4Zo3KzrC29YZGBgGU. Cache-busted /api/health = {version 2.24.4, deploymentId dpl_22dny79m5tQ4Zo3KzrC29YZGBgGU, dbOk true, ts 2026-10-06T14:47:05Z}. The deploymentId floor is met and the version match corroborates it. NOT EXERCISED - coverage limits, stated rather than elided: - The Class-A Vercel BUILD and RUNTIME LOG reads for this SHA were NOT performed. The instrument is down, not the logs absent: the Vercel MCP connection now returns list_projects = [] and get_project 404 for prj_0t8ww1LnJr4IzXrKzLFr56b4mI5t, get_deployment / list_deployment_events 404 for dpl_22dny79m5tQ4Zo3KzrC29YZGBgGU, list_deployments 403, get_runtime_logs 403 both deployment-filtered and project-wide. list_teams still returns both teams, so the token holds team membership and no project grant. No vercel CLI exists on this host (not on PATH, not in node_modules/.bin), so the documented CLI fallback is unavailable. The GitHub commit status 'success' is the build-OUTCOME fact the build log would have been read for, but it is not the log read and is not being passed off as one. Filed separately as a shared-rail capability loss affecting every Evolutiva app's Class-A PTD, not a Pluto defect. - isDocente is FED to the mock, so the EXISTS over vUserRolesActive is never executed against Postgres by these tests - they prove the JS consumes the column correctly, not that the SQL computes it correctly. - vUserRolesActive carries no live-users join (the PLUTO-782 subject), so a soft-deleted principal's role rows still satisfy the EXISTS: a deleted docente who is also on the roster would be marked as dual-role. That is the pre-existing view defect, unchanged here and tracked on PLUTO-782, not introduced by this change. - NO production exercise: no teacher-filed practica with a dual-role participant was created on prod, so no appEvents row has been observed in the wild. The mark is unproven on real data by design - this change creates no test rows.
-
Done at e4885ee, v2.24.4, live (deploymentId dpl_22dny79m5tQ4Zo3KzrC29YZGBgGU). Ruling C implemented as a column on the query that already had to run: the gate's decision is unchanged, the dual-role fact is recorded post-COMMIT post-RELEASE as appEvents authz/info action practicaTeacherParticipantDualRole. Fire and no-fire shown in the same run, four forced reds. The Class-A Vercel build/runtime log read is the one open gap - the instrument is down (project and deployment reads 403/404, no vercel CLI on host), disclosed in full on the verified event and filed separately as a shared-rail loss.
-
NAMED WAIVER of the Class-A log read, per pmmaster-evolutiva-cc ruling 2026-10-06 (tag pmmaster-evolutiva-cc-muwstix8wuot-coder-pluto-cc). The Vercel BUILD and RUNTIME LOG reads for e4885ee were NOT performed and are waived, citing OPS-293. The instrument is down, not the logs absent - Vercel MCP list_projects=[], get_project 404, deployments 403/404, runtime logs 403; no vercel CLI on venus. PTD proceeded on the reachable floor: gh commit-status for e4885ee = success, and the prod /api/health deploymentId dpl_22dny79m5tQ4Zo3KzrC29YZGBgGU matching the commit-status target_url id. The build-status 'success' is an outcome, not a log read, and is not substituted for one. When OPS-293 clears, this row gets its log read retroactively or stays marked unexercised - it is not to be quietly dropped.