Who may attribute a named ayudante: createPractica gates on PRACTICA_ROLES only, so any participant can assert another person attended a surgery, and rolPropio NULL is undecided
Found by audit-terra-ca in a bounded closure audit of TERRA-129, TERRA-139 and TERRA-140 against current source, 2026-08-31. The first two passed on work actually delivered. TERRA-140 did not: its form and data path are present and correct, but the authorization question its own title implies was never decided - it was defaulted to by the code. TERRA-140's title says the operador selects the ayudante. Source does not implement that. practica-form.tsx exposes the Ayudante picker unconditionally to every practice-authorized user; PRACTICA_ROLES is alumno, docente, jtp, bedel and admin (route-access.ts). rolPropio is OPTIONAL on the form, and createPractica gates only on PRACTICA_ROLES - it never requires rolPropio = operador before buildParticipantes writes the named ayudante. So an alumno, an observador, someone who declared themselves ayudante, or staff can assert that another named person attended a surgery. This is not an implementation failure hidden behind a green test, and not a precondition the row closed on. It is an unresolved product decision that source has silently answered as "any PRACTICA_ROLES actor may attribute". A default chosen by omission is indistinguishable in the code from a default chosen deliberately, which is why it needs a decision recorded here rather than a patch. The decision to make, in two parts. FIRST: may only the operador name an ayudante, or may any participant? SECOND, and it is the part that will be skipped if not written down: what happens when rolPropio is NULL. It is optional today, so an operador-only rule has to say whether a null role is treated as not-operador (refuses a legitimate operador who left the field blank) or as permitted (reopens the hole the rule was written to close). Neither is obviously right and the answer determines whether the field must become required. Enforcement must be SERVER-SIDE in createPractica, not a hidden picker. Hiding a control is not removing a capability: the action accepts the participantes array regardless of what the form rendered, so gating the render alone leaves the write path open to a stale tab or a direct call. That is the same shape as the TERRA-139 URL-predicate gap. NOT the same row as TERRA-146. TERRA-146 asks whether a named person can remove themselves after the fact - the correction path. This asks who may name them in the first place. They are the two halves of one attribution problem and each needs its own answer; folding them would close one on the other. Bound on the finding: audit-terra-ca read source and tests and ran typecheck. It did not exercise an authenticated browser, the live database, or a direct server-action call, so this is a source-level reading of the gate, not an observed exploit. Close condition: the two questions above are answered by Elazar or the client, and the answer is enforced in createPractica with an arm that fails when the gate is removed.
Activity
-
Both decisions filed by coder-terra-ca as EQ #64 (may only an actor with rolPropio=operador name an ayudante, or any PRACTICA_ROLES actor) and EQ #65 (must rolPropio NULL refuse attribution and force an explicit role, or remain permitted). No policy was selected by the implementing lane, which is the correct handling for a decision row - the failure mode this row exists to name is a default chosen by omission, and an agent picking one to unblock itself would reproduce it. Blocked pending Elazar or the client. Enforcement when it comes is server-side in createPractica; hiding the picker does not remove the capability.
-
EL CASO NO ES HIPOTETICO: YA PASO. Medido por db02-terra-cc, SELECT solamente, con denominador, 2026-09-01 08:52. Es la primera de las filas medidas hoy que NO da cero. LA POBLACION: 2 filas vivas de participantes sobre SEIS practicas totales. Cinco practicas no tienen ninguno. La unica que tiene, tiene dos filas: el creador de la practica con rol operador, y OTRO usuario distinto con rol docente, las dos con el mismo createdAt exacto - alta conjunta en el mismo insert. O sea que practicas donde alguien fue nombrado por otro: UNA, no cero. La situacion que estas dos filas describen existe hoy en produccion, sobre una persona real. LA ESTRUCTURA, y aca hay una correccion a como yo venia escribiendo estas filas: la columna NO se llama rolPropio. Se llama rolEnPractica, es text NOT NULL, y esta en terraPracticasParticipantes. Yo venia arrastrando el nombre equivocado. La tabla tiene seis columnas: id, practicaId con FK a terraPracticas, userId con FK a users, rolEnPractica, createdAt y deletedAt. LO QUE NO EXISTE, Y ES LO QUE HACE QUE LA CORRECCION SEA INAUDITABLE AUNQUE SE CONSTRUYA: no hay ninguna columna que registre QUIEN agrego a cada participante. Nada tipo addedBy. createdBy vive en terraPracticas, no aca. Asi que una vez escrita la fila, la base NO PUEDE DECIR si la persona se puso sola o la puso otro. Y eso vale hacia atras tambien: la fila que ya existe, con dos participantes cargados en el mismo insert, no dice quien nombro a quien. La unica pista posible estaria fuera de esta tabla, en appEvents, si algun escritor guarda el actor real - db02 lo marco explicitamente como NO VERIFICADO porque no era parte de lo pedido, y yo lo dejo asi en vez de asumirlo. SACARSE A SI MISMO ES TECNICAMENTE POSIBLE HOY. deletedAt existe, o sea que sacar a alguien es un soft-delete y no un borrado - forbidHardDelete lo impide de todas formas. Y medido contra pg_trigger, no contra el nombre de las cosas: la tabla tiene OCHO triggers no internos - archivo en DELETE, archivo en UPDATE con rama de soft-delete, forbidHardDelete, forbidTruncate, requireArchiveCtx, requireTerraMembership y vocabGuard sobre rolEnPractica. NINGUNO registra ni restringe el ACTOR de la operacion. requireTerraMembership gatea que el userId sea miembro de terra, no quien hace la operacion. Asi que nada impide hoy que una fila se marque como borrada por cualquiera: ningun trigger compara userId contra el actor de la sesion. CONSTRAINTS: PK, las dos FK, y un indice unico parcial en (practicaId, userId, rolEnPractica) donde deletedAt IS NULL - evita repetir el mismo rol para la misma persona en la misma practica. No tiene nada que ver con la auto-remocion. LO QUE ESO LE HACE A LAS DOS FILAS. TERRA-149 pregunta QUIEN PUEDE atribuir un ayudante: sigue siendo una decision de politica y no cambia. TERRA-146 pregunta si la persona nombrada puede sacarse: la respuesta tecnica es que HOY PUEDE CUALQUIERA, porque no hay superficie construida pero tampoco hay nada en la base que distinga a la persona nombrada de cualquier otro actor. O sea que la pregunta no es "hay que permitirselo", es "cuando se construya la superficie, la base no va a poder ayudar a decidir quien tiene derecho, porque no guarda quien puso a quien". Y ESO ES UNA DECISION QUE HAY QUE TOMAR ANTES, NO DESPUES: si se quiere que la nombrada pueda sacarse y otros no, hay que empezar a guardar quien agrego a cada participante. Con DOS filas en la tabla, agregar esa columna es gratis. El dia que haya doscientas, las viejas no lo van a tener nunca.
-
coder-terra-cc
-
coder02-terra-ca
-
Bloqueado en dos decisiones de politica: si solo quien declara rol operador puede nombrar ayudante o cualquier PRACTICA_ROLES, y como tratar rol propio ausente. La respuesta debe quedar aplicada del lado servidor en createPractica; ocultar el picker no cierra la capacidad.
-
Independent audit PASS 2026-09-10: blocker states the actual pending product decisions and makes no unproven implementation claim.
-
coder02-terra-cc
-
DECIDED. Marker: T149-dec-mtwz02. Decided by the terra PM lane on 2026-09-11 under Elazar's 09:27 ART standing delegation. Not Elazar's own wording; overrule this row specifically if wrong. THE CLIENT ALREADY ANSWERED THIS AND THE ANSWER WAS SITTING IN pm/05-client-info.md:371. Sebastian Puia, WhatsApp 2026-08-30 11:29, verbatim: "aparece la opcion rol en la Cirugia... Podriamos Hacer que el q opera pueda seleccionar al ayudante (asi uno solo realiza la carga y figura todo el team quirurgico)". He proposed the feature AND named who does it in the same sentence: EL QUE OPERA. This is not an inference from silence, it is the requester's own scoping of his own request. DECISION 1 (eq 64) - who may name a specific user as ayudante: ONLY a participant whose rolPropio is operador. Enforced SERVER-SIDE in createPractica, not by hiding the picker - hiding a control does not remove the capability, and the row already says the current source silently permits any PRACTICA_ROLES user. DECISION 2 (eq 65) - rolPropio NULL: NULL IS NOT operador and the attribution is REFUSED. Reason: the gate must fail closed, and the alternative preserves exactly the broad authority decision 1 just removed. Consequence to implement deliberately: naming an ayudante requires the namer to have declared their own role, so the form must make rolPropio explicit on that path rather than silently accept NULL and then refuse at submit. This is also the narrower of the two options on an accountability question, which is the right direction for a default nobody has ratified: a wrong narrow default costs a form field, a wrong broad default lets any participant assert that a named person attended a surgery.
-
policy decided T149-dec-mtwz02: operador-only ayudante attribution, NULL rolPropio refuses, enforced server-side in createPractica
-
BUILT AND COMMITTED, NOT YET PUSHED. Marker: T149-built-mtwz1. Local commit d816ca7, four files: src/lib/practica-participantes-core.ts, src/lib/practica-participantes-core.test.ts, src/lib/practica-actions.ts, src/components/practica-form.tsx. THE RULING IMPLEMENTED AS GIVEN, not as I had proposed it. Operador only, and a NULL rolPropio refuses. My own recommendation on this row had been wider - the author whatever their rol, plus the docente controlador - and the narrower one is right for the reason coder-terra-cc gave: the client scoped it himself in one sentence and named the actor in it, so the literal reading is available and an invented wider one is not. WHAT LANDED, THREE PIECES. A named constant and a predicate in practica-participantes-core.ts, so the rule is readable in one place and the form and the action cannot drift apart. A refusal in createPractica, placed BEFORE buildParticipantes because that function returns rows and has no way to report a refusal - a check after it would be checking something already built. And the form renders the ayudante control only for whoever says they operaron, replacing it with a sentence saying why rather than greying it out, because a disabled control with no reason next to it reads as a bug. TWO MESSAGES, NOT ONE, and the distinction is the useful part: "you did not say what you did" and "you said you did something else" are different mistakes, and only the first one is fixable by filling in the field directly above it. CHANGING YOUR OWN ROL CLEARS A NAMED AYUDANTE rather than leaving it in form state to be submitted and refused. Someone who picks operador, names a colleague, then corrects their own rol did not mean to keep the claim, and silently carrying it turns a correction into an error message about a field that is no longer on screen. WHY THE SERVER CHECK IS THE ONE THAT MATTERS, and it is not a theoretical point on this table. The action is reachable by any authenticated PRACTICA_ROLES user with a crafted payload, and the row it writes asserts that a THIRD PERSON attended a surgery. That assertion renders on that person's own cursada page with nothing beside it they can act on - TERRA-146 measured the table and there is exactly one INSERT in the whole application, no UPDATE, no DELETE, no removal path for anybody. So an unauthorised write here is not correctable afterwards, which is what makes hiding the picker insufficient rather than merely weak. TESTS: two added, 526 pass, 0 fail, tsc clean. The first pins all three arms of the predicate by name rather than by looping over SELF_ROLES, so adding a rol to that list cannot quietly widen who may name an ayudante. The second reads the action's source and asserts both that the refusal is there and that it precedes buildParticipantes, because the action itself needs a database to run and the failure it guards against is precisely a payload that never went through the form. COVERAGE STATED HONESTLY: not exercised against the live database - no credential on this lane - so what is verified is the predicate, the position of the check and the form's behaviour, not an observed server rejection. PUSH IS BLOCKED, not by my work. Six commits from three other lanes sit ahead of origin/main on this shared checkout (db-terra-cc x3, coder-terra-cc, coder03-terra-cc, db02-terra-cc), and a branch push ships everything ahead, so the gate refuses until the holders clear it. Asked coder-terra-cc, who holds the push lane and has a commit in the range, to push the whole range.
-
Built and committed locally at d816ca7; push blocked by six other lanes' commits ahead of origin, handed to the push lane.
-
SHIPPED AND PUSHED. Marker: T149-close-mtx1a. In the 1ad80b8..116e809 range, thirteen commits across five lanes in one push; my two are d816ca7 and 3af1280. WHAT THE ROW ASKED AND WHAT ANSWERS IT. The question was who may attribute a named ayudante, with rolPropio NULL explicitly undecided. Both halves are now decided and enforced: operador only, and NULL refuses. The decision is coder-terra-cc's under Elazar's 09:27 standing delegation, taken from the client's own sentence rather than from a default any of us preferred - my own recommendation on this row was wider and is withdrawn. ENFORCEMENT IS SERVER-SIDE, which is the part that closes the defect rather than hiding it. The check sits in createPractica before the participante rows are assembled, so a crafted payload from any authenticated PRACTICA_ROLES user is refused the same as a form submission. The form's conditional picker is the courtesy half only. COVERAGE, STATED HONESTLY: 526 tests pass, typecheck clean, the push gate green. NOT exercised against the live database or the deployed app - no credential on this lane and the agent is under a work freeze until 17:00 UTC - so what is verified is the predicate, the position of the check in the action, and the form's behaviour. An observed server rejection has not been seen. Whoever next has a live session can close that in one submission. WHAT THIS ROW DOES NOT COVER, so it is not read as wider than it is: it governs WRITING the claim, not undoing one. A wrongly named ayudante still has no removal path for anybody - that is TERRA-146, which now sits with db-terra-cc.