basquetWi + New ticket
terra TERRA-139

Informes: alumno filter for docentes - the practica count is unattributable without it, and the client read it wrong

Done high ctcoder-terra-ca

CLIENT-REPORTED, and the only NO in a self-test table he otherwise passed. Row 6 of the "Para probar vos mismo" table came back "Si, pero no puedo seleccionar alumnos." He then raised it three more times unprompted in the WhatsApp thread, which no other item in the document got. His words, 2026-08-30 11:31 and 11:32: "Hay q poner q el docente PUEDA PONER EN LOS FILTROS 'los alumnos'" and "Yo creo que veo 6 practicas... q son las totales, pero como no puedo seleccionar alumnos en filtros no se si son del alumno Puia o de todos... se entiende? Hay q agregar en filtros alumnos, SOLO PARA LOS PROFES, para identificar las practicas de cada alumno en particular." WHY THIS IS WORSE THAN A MISSING FILTER, and it is the part worth fixing first: the number he is looking at is AMBIGUOUS, not merely unfiltered. He sees 6 practicas and cannot tell whether that is the catedra total or his own. An Informes screen whose totals cannot be attributed is not a reduced feature - it is a screen that can be read wrong, and he read it wrong in front of us and said so. Whatever the filter ends up looking like, the count must become attributable. SCOPE HE SET HIMSELF: "SOLO PARA LOS PROFES". The alumno filter is docente-visible only, never in the alumno menu - an alumno seeing a roster picker is a disclosure change, not a convenience. Terra already gates by role, so this rides existing machinery rather than inventing a rule. DO NOT ASSUME THE FILTER IS THE WHOLE JOB. Check first whether the 6 he sees are already scoped to him or are the catedra's - if Informes is already returning catedra-wide rows to a docente, the filter is the second half and the first half is what the query returns. Establish which before writing UI. That is a measurement, not a design decision. Related: TERRA-125 covers sortable tables, search and pagination across terra's three table pages, and this is a filter on one of them - check the overlap before building a bespoke control that TERRA-125 then has to reconcile.

Sub-tickets

No sub-tickets.
+ Add sub-ticket

Activity

  • wi-cli-venus created · 6w ago
  • wi-cli-venus note · 6w ago
    MEASURED by db02-terra-cc, and it is WORSE than the client's report: Informes is catedra-wide with NO user scoping of any kind. InformeFilter in informe-filter-core.ts carries 7 axes - desde, hasta, moduloId, cirugiaId, complicacionIntra, complicacionPost, tipoInjerto, marcaImplante - and no docente or creator id among them. FILTER_SQL in informe-query.ts filters on those 7 plus p."deletedAt" IS NULL, with no user predicate. vTerraPracticas, the view runInforme reads, carries no docenteId or createdBy column at all. requireAppUserPage(INFORME_ROLES) gates WHO MAY OPEN THE PAGE, not WHICH ROWS COME BACK - that distinction is the finding. SO THE 6 HE SEES ARE EVERY PRACTICA IN THE CATEDRA, and his reading was right: they are not his. This also means the filter is NET-NEW CAPABILITY, not a restoration of scoping that got lost - there is nothing to re-enable, and the view has to gain a column before any filter can select on it. That changes the shape of the work: it is a view change plus a filter axis plus UI, and the view change is db-terra-ca's lane, not coder-terra-ca's. It also means the current screen is not merely unhelpful to a docente - it is a screen whose number cannot be attributed by anyone, including us.
  • wi-cli-venus spec · 6w ago
    DESIGN SETTLED. Semijoin plus the existing page gate; NO new database report function. THE PREDICATE, from db-terra-ca and accepted verbatim: EXISTS (SELECT 1 FROM "vTerraPracticasParticipantes" pp WHERE pp."practicaId" = p.id AND pp."userId" = $n::uuid). A semijoin rather than a JOIN, and the reason is specific rather than stylistic - buildParticipantes deliberately writes TWO ROWS for one person holding two roles, so a JOIN plus count double-counts a practica held by someone who both operated and supervised. Omitted uuid means catedra-wide, as today. No view projection migration is needed: vTerraPracticasParticipantes already exposes practicaId, userId and rolEnPractica. createdBy IS NOT THE KEY, and this corrects an earlier note on this item. The view DOES carry createdBy - terra_40:83, verified in the file - so the earlier "no createdBy column" premise was wrong; only the docenteId half of that claim was right. But createdBy is the record AUTHOR, not participant identity, and filtering on it is wrong for two independent reasons. Semantically it does not answer "las practicas de cada alumno". And it would test GREEN ALL WEEK: today author and participant are effectively the same person, so nothing would fail - until TERRA-140 ships, at which point the operador loads one practica naming the team and every ayudante's practica carries someone else's id and vanishes from their own filter. Two items in flight, and the cheap implementation of one silently defeats the other about a fortnight later with no failing test in between. NO DB BOUNDARY FUNCTION IS EARNED, and the measurement is why rather than a judgement call. INFORME_ROLES is ["client","docente","jtp","bedel","admin"] at route-access.ts:55 - alumno is EXCLUDED, and informes/page.tsx:29 says so in a comment. So every caller who can open /informes ALREADY retrieves the full catedra-wide report. The filter is a NARROWING operation over a set the caller already has, it cannot widen anything, and a DB-enforced gate on it would protect nothing. THE DISTINCTION FROM TERRA-130/131 IS ON THE RECORD SO THIS IS NOT READ AS RELAXING THE STANDING RULE: those are WRITE paths where the app-side check is the only thing between a caller and shared state, and bypassing the action destroys the invariant. This is a READ path whose entire result set is already authorised, where bypassing the filter returns MORE rows the caller may already see. Terra enforces cardinality and vocabulary in the database; that is not the same as every predicate needing a function. THE REAL GAP IS THE PICKER'S OPTIONS, NOT THE PREDICATE, and it was found only because db-terra-ca asked the authorization question. Offering a picker means rendering a LIST OF ALUMNOS, and that is the disclosure. INFORME_ROLES includes "client"; ROSTER_ROLES, which governs /alumnos, is ["docente","jtp","bedel","admin"] and does NOT. So a user holding client alone can reach /informes but not /alumnos, and a picker populated under INFORME_ROLES hands them a roster the app deliberately withholds one route over. POPULATE THE PICKER UNDER ROSTER_ROLES. Puia holds both client and docente, so he is unaffected - which is precisely why this would have shipped green and been found by somebody else later. OPEN, FOR ELAZAR AND PROBABLY THE CLIENT: terra has NO docente-to-alumno assignment relationship at all - db-terra-ca traced it and the /alumnos page states it. So "SOLO PARA LOS PROFES" can only mean docentes get the catedra-wide picker. If Puia meant "each docente sees only their own students", that relationship does not exist and is a new feature, not a filter. Not resolving that by building the version we happen to be able to build.
  • wi-cli-venus decision · 6w ago
    Design ruling from coder02 via db-terra-ca: optional alumno filter uses a validated alumno UUID and EXISTS against live vTerraPracticasParticipantes keyed by userId; never createdBy and never a JOIN, since a person may have multiple participant-role rows and a JOIN could double-count. Omitted filter remains catedra-wide. Render the picker only for ROSTER_ROLES (docente, jtp, bedel, admin), not page-level INFORME_ROLES: client may access Informes but must not receive the alumno roster. Predicate may remain callable by authorized report callers because it only narrows data. No production change yet.
  • wi-cli-venus statusChanged · 6w ago
    Design settled and dispatched 2026-08-31. coder-terra-ca owns the row and the UI half (picker options under ROSTER_ROLES, page under INFORME_ROLES). db-terra-ca is drafting the query half only: EXISTS semijoin on vTerraPracticasParticipantes, no JOIN, no new boundary function, no view migration. Both dispatched by DM this turn after both panes were measured idle rather than working. No apply authorised.
  • wi-cli-venus spec · 6w ago
    DISPATCH RECORD - recorded here rather than only in a DM, because coder-terra-ca lost the first dispatch to a compact and then read an EMPTY inbox, which reads as "nothing was assigned". A hub message lives only in the recipient's context window, which is the one component guaranteed to be discarded. This event survives. QUERY HALF - DELIVERED by db-terra-ca, verified by me (files exist, read, and re-derived from source): agents/db-terra/evidence/2026-08-31-terra_139-informe-alumno-filter.sql + .result.txt Result: 6 catedra-wide practicas vs 1 for the selected alumno, filter_only_narrows=true. APP HALF - coder-terra-ca: 1. alumnoId: string | null on InformeFilter, validated as UUID, appended as $9. Null = catedra-wide, existing behaviour unchanged. informe-query.ts currently ends at $8 (verified by grep). 2. Predicate: AND ($9::uuid IS NULL OR EXISTS (SELECT 1 FROM vTerraPracticasParticipantes pp WHERE pp.practicaId = p.id AND pp.userId = $9::uuid)) 3. EXISTS, NEVER A JOIN. buildParticipantes writes TWO rows for one person holding two roles - practica-participantes-core.ts:49-53 states this in its own comment and I re-read it. A JOIN multiplies the report CTE. THE DRY RUN DOES NOT DEMONSTRATE THIS. The selected fixture had one role row, so raw_join_rows happened to equal the EXISTS count and the contrast did not discriminate. The requirement rests on the SOURCE, not on that measurement - which is why it is written down here, so nobody "simplifies" it to a JOIN later on the grounds that the numbers matched once. 4. THE PICKER'S OPTIONS ARE GATED SEPARATELY FROM THE PAGE. Page = INFORME_ROLES. Picker options = ROSTER_ROLES. client is in INFORME_ROLES and NOT in ROSTER_ROLES (route-access.ts), so a client-only user reaches /informes but is refused at /alumnos - populating the picker under the page's own constant hands them the roster the adjacent route withholds. Two constants on one screen, deliberately. The reason goes in a code comment; this is the shape the next editor collapses to one.
  • wi-cli-venus statusChanged · 6w ago
    Application implementation complete locally: validated alumno UUID as ; participant EXISTS semijoin (no JOIN); roster picker loaded/rendered only for ROSTER_ROLES while page stays INFORME_ROLES. Full unit suite and typecheck pass; source is uncommitted and not deployed.
  • wi-cli-venus verified · 6w ago
    2026-08-31 coder-terra-ca: pnpm test (410 passing) and pnpm typecheck passed after local implementation; git diff --check passed. No authenticated browser or deployment exercise performed.
  • wi-cli-venus audit-finding · 6w ago
    DEFECT FOUND IN THE DELIVERED IMPLEMENTATION - uncommitted, so it costs nothing now and would have shipped. Found by reading the diff rather than the report; 410/410 tests, typecheck and git diff --check all passed with this present. WHAT IS CORRECT: informe-query.ts:84-86 uses the EXISTS fragment with no JOIN (grep for JOIN.*Participantes across src/lib returns nothing). getAlumnos is scoped by appKey and group='alumno'. informes/page.tsx:81 gates the FETCH, not merely the render - maySeeAlumnoPicker ? getAlumnos() : Promise.resolve([]) - and page.tsx:187 gates the render as well. Both halves of the disclosure boundary are real. THE GAP: GATING THE PICKER IS NOT GATING THE PREDICATE. parseInformeFilter reads params.alumno unconditionally (informe-filter-core.ts:104), and page.tsx:86-87 runs parseInformeFilter then runInforme with no reference to maySeeAlumnoPicker. So a client-only user - in INFORME_ROLES, not in ROSTER_ROLES, refused at /alumnos - who requests /informes?alumno=<uuid> gets the predicate APPLIED. The control is hidden; the capability is not removed. TWO CONSEQUENCES, and the second is the one that matters: 1. UI incoherence. activeFilterCount counts every non-null field (informe-filter-core.ts:117-119) and isUnfiltered is its mirror, so the page reports "filtered" with a clear-filters link and NO VISIBLE FILTER. page.tsx:84-86 states the rule this breaks in its own comment: "A filter shown in the form but not applied (or the reverse) is a report that lies about its own scope." The reverse case is now live for client users. 2. IT IS AN ORACLE FOR THE ROSTER THE GATE WITHHOLDS. Feed candidate uuids, keep the ones that narrow to a non-zero count: that confirms which user ids are alumnos with practicas. Weaker than handing over the list because it needs candidate uuids first - but it is the same disclosure, obtained one probe at a time, and terra user ids are not secrets. FIX: strip alumnoId when the viewer may not see the picker, BEFORE reconcile and runInforme. const requested = parseInformeFilter(params); const parsed = maySeeAlumnoPicker ? requested : { ...requested, alumnoId: null }; const filter = reconcileCirugia(parsed, cirugias); Null, not a refusal: a bookmarked or hand-edited URL should widen to the report the viewer is entitled to, not error. Same treatment reconcileCirugia already gives a mismatched cirugia. TEST ARM REQUIRED, and the existing "client-vs-roster capability" arms do not cover it: they assert the picker is not rendered. Assert instead that a client-role viewer submitting alumno=<uuid> produces alumnoId null in the filter that reaches runInforme. The rendered-picker assertion passes today WITH the defect present, which is why it read as covered. CLASS: the app-side check was the only thing standing between the caller and the narrowed result, and hiding a control is not a check. Note this does NOT reverse the standing TERRA-139 ruling that the predicate earns no DB boundary function - the result set is still one the caller may read in full. What leaks is ATTRIBUTION, which is the whole thing Puia asked for and the whole thing /alumnos withholds from client.
  • wi-cli-venus verified · 6w ago
    2026-08-31 follow-up after coder02 audit: non-roster viewers now have alumnoId cleared before reconcileCirugia/runInforme; added direct gateAlumnoFilter regression arm. pnpm test (411 passing), pnpm typecheck, and git diff --check passed. Still uncommitted and undeployed.
  • wi-cli-venus completed · 6w ago
    Shipped in main 34f3167, the same commit as TERRA-140, and live in production. The gate is wired: informes/page.tsx:89 wraps the parsed filter in gateAlumnoFilter(requested, maySeeAlumnoPicker) before reconcileCirugia, and maySeeAlumnoPicker is computed against ROSTER_ROLES while the page itself admits INFORME_ROLES - which is the whole defect, since client is in the second set and not the first. WHY THE GATE AND NOT JUST THE PICKER: hiding a control is not removing a capability. parseInformeFilter reads params.alumno unconditionally, so gating only the render and the getAlumnos fetch would have left the query string itself working as a roster oracle for a client - and activeFilterCount counts every non-null field, so the page would additionally have reported itself as filtered with no visible filter. EVIDENCE AND ITS LIMIT, stated rather than implied: coder-terra-ca reports verifying the call from the committed blob; I confirmed the same line in the working tree and the deployment is READY from that sha. Nobody has exercised it as a signed-in client, because this lane has no authenticated session. So the wiring is verified and the BEHAVIOUR is not.
task
6w ago by wi-cli-venus
6w ago
2026-08-31 11:22