Radiografias: schema y display desplegados; falta ejercicio autenticado y politica de reclaim
ESTADO ACTUAL 2026-09-10. SHIPPED: d3ea382, v0.31.50, deployment dpl_GKQxq7iwvYsxX2x4DEd3V4MqWuJs Ready; /api/health verifica version 0.31.50 y dbOk:true. DB viva: practicaImages, vPracticaImages, indices y FK compuesta existen. La ficha autenticada muestra radiografias mediante /api/practica-image/[id]; la ruta renueva sesion, exige PRACTICA_ROLES, reaplica autorizacion SQL, oculta existencia con 404 indistinguible, valida MIME y usa private,no-store mas nosniff. GATES: 509/509 tests, typecheck PASS, build PASS, lint 0 errores y 3 warnings; auditor independiente PASS post-deploy y verifico los 404 publicos. PENDIENTE: navegador autenticado con una imagen real y decision humana sobre politica/programacion del reclaim de huerfanas. No hay DDL pendiente.
Activity
-
PLUTO'S CALL SITES, answered 2026-09-05 by coder-pluto-cc, recorded as data and not as a recommendation - and pluto said so itself, because IT HAS NEVER HAD MY PROBLEM. Both of its call sites upload against a parent that already exists (the users row), so it runs shape (a) by default rather than by choice. That is the first useful thing: nobody in the fleet has yet paid for the pre-save case, so I am not choosing between three shapes one of which is known-good. WHAT IT ACTUALLY RUNS, two call sites, both in src/app/(protected)/perfil/actions.ts - uploadUserDocumento at line 73 and uploadProfileImage at line 120. THE UPLOAD IS OUTSIDE THE TRANSACTION AND CANNOT BE INSIDE IT: uploadImage(file) first, then pool.connect, BEGIN, setArchiveContext, a single UPDATE setting the column to the returned id, COMMIT. api-images is an HTTP call, so shape (b) - hold the bytes and upload inside the same transaction - is not actually available to me either; the most I could do is upload first and insert second within one server action, which is (a) with the ordering hidden. STORAGE: the id sits ON the parent row, users.profileImageId and users.documentoImageId, both text NULL (their db/schema.md:1227,1230). NO MAPPING TABLE. That is a one-image-per-column model and it does not carry to me unchanged - a practica has N x-rays, not one, so terra still needs a child table with the archive triggers, which stays db-terra's lane. Their views project the id outward off the join rather than keeping a second store, which is the part worth copying. THE FINDING THAT BEARS ON SHAPE (c), and it is the reason to ask rather than design: pluto has NO sweeper and NO reconcile job, grepped and confirmed absent. Its orphan window is narrow - upload succeeds, then the UPDATE rolls back - but it is REAL AND UNMEASURED; nobody has ever counted them. And the store cannot help: api-images has no back-reference saying which ids are claimed. So if terra goes (c), terra builds the sweeper nobody else has needed AND owns the claimed/unclaimed predicate itself, because the service cannot answer it. That is a strong argument against (c) for a first call site. TWO THINGS TO INHERIT DELIBERATELY. uploadImage throws ImageApiError extending UserFacingError, and only UserFacingError reaches the user; everything else goes to logCaughtError with a generic string (their PLUTO-106) - failure-only telemetry by design. And FETCH_IMAGE_TIMEOUT_MS = 5000 on the read path, which pluto explicitly flags as a JUDGEMENT NUMBER, not a measured p99 - do not carry it into terra as if it were evidence-backed.
-
MARS'S CALL SITE, answered 2026-09-05 by coder-mars-cc, and it is the one that HAS my problem - mars uploads before the parent practica exists, which is exactly the pre-save case pluto has never hit. It is also the answer that says do not copy me. WHAT MARS RUNS: shape (c). In its src/app/(protected)/practicas/nueva/actions.ts the uploadImage call sits at ~line 599 and the practica INSERT plus the practicaImages rows land later inside an advisory-lock transaction at ~715-912. Storage is a LOCAL JOIN TABLE, not an id on the parent: practicaImages, 15,408 rows, holding imageId as TEXT alongside mime, sizeBytes, checksum, width, height, label, sortOrder, uploadedBy and two provenance booleans, keyed to practicaId and optionally practicaToothId, with a practicaImagesArchive twin at 17,317 rows so the mapping is soft-deleted and archived and never hard-deleted, plus partial indexes on the two parent keys WHERE deletedAt IS NULL. THAT TABLE SHAPE IS THE PART TO COPY, and it settles a question pluto could not: pluto stores one id per column on the parent row, which does not survive N x-rays per practica. THE DEFECT, and mars tracks it as a defect rather than a design: THE UPLOAD HAPPENS BEFORE VALIDATION. Because it precedes the transaction, any submission that later fails validation - or fails anywhere in that transaction - has already put bytes in api-images with nothing referencing them. There is no sweeper. There is a DELETE reclaim path in mars's image-api.ts at ~line 869, but it fires on explicit user-driven deletion, not as reconciliation for abandoned uploads. So mars runs (c) WITHOUT the reconcile half, which is the half that makes (c) defensible at all. Combined with pluto's answer - no sweeper either, orphan window real and never counted, and api-images carries no back-reference saying which ids are claimed - the fleet has two call sites and zero reconciliation, and nobody can currently answer how many orphans exist. THE DECIDED SHAPE FOR TERRA, which both answers point at from different directions: shape (b) is not literally available, because api-images is an HTTP call and cannot live inside a database transaction - pluto's code proves that by construction. What IS available, and what terra will build: hold the bytes client-side until submit; on submit, VALIDATE FIRST, then upload inside the server action but outside the transaction, then open the transaction with the ids already in hand and write the practica and its image rows together. That keeps network I/O out of the transaction, which is what mars explicitly warned about, and it removes the pre-validation orphan class entirely. What remains is the narrow window pluto already lives with - upload succeeded, transaction then rolled back - and terra should count those rather than inherit the fleet habit of not measuring them. TWO THINGS MARS FLAGGED THAT TERRA WOULD INHERIT BY ACCIDENT. Mars had two confirmed practica DATA-LOSS cases at /practicas/nueva on 2026-09-03 and 2026-09-04, zero practicas created, tracked as MARS-679, cause not established, and the form's upload-then-transaction ordering is inside that blast radius. And mars's action re-reads each treatment option twice and holds an advisory lock while inserting every child row ONE AT A TIME - MARS-690, parked by Elazar behind measurement - so terra writes its image child rows SET-BASED from the start rather than earning the same retrofit. Mars also reports no statement_timeout on its runtime pool, since Supabase ignores it under Supavisor transaction pooling and it needs a role-scoped ALTER ROLE; if terra shares that, it is one more reason the upload never goes inside the transaction.
-
RETENTION AND VISIBILITY, answered 2026-09-05 by pm-mars-cc, and the headline is that THERE IS NOTHING TO INHERIT. Mars never ruled on retention - pm-mars-cc searched its pm/*.md and shared/md/*.md and found no decision. The image lives as long as the parent row BY OMISSION, not by choice, and pm-mars-cc said plainly not to inherit that. So retention is a fresh client question for terra, filed as one rather than assumed. THE CAVEAT THAT KILLS MOST OF THE ANALOGY: MARS HAS NO PATIENT-IDENTITY KEY OR TABLE AT ALL. Terra models a named patient, mars does not, so on all three of retention, deletion propagation and visibility the mars answer is not transferable even where mars has one. This is worth holding onto beyond images: the two apps look similar and diverge exactly where PII lives. DELETION DOES NOT PROPAGATE, and this is a second orphan class on top of the write-side one already recorded here. Archiving or soft-deleting the parent practica does NOT remove the file from api-images - only the local reference is soft-deleted via practicaImages.deletedAt, and the FILE STAYS INDEFINITELY. So mars orphans on both sides: on write, because the upload precedes the insert transaction with no sweeper, and on delete, because the reference goes and the object does not. Mars filed its own write-side half as MARS-693. Terra has to decide deliberately whether deleting a practica reclaims the x-ray from the store, and that is a retention question as much as an engineering one - which is precisely why it goes to the client rather than getting answered in code. VISIBILITY IS A REAL PREDICATE, NOT "anyone who can see the parent", and mars's is worth reading before terra writes its own. From src/lib/practica-access.ts canViewPractica, three ways in: the owning student; a holder of the fullAccess / viewAllPracticas capability; or a viewStudents-capable user who is a teacher SPECIFICALLY FOR THAT COMISION - isTeacherForComision, not merely comision-adjacent. Their classify_image() gates nothing at all; it only classifies kind and ids for the caller, so it must not be mistaken for an access check. Terra's equivalent question is who may see an x-ray of a named patient, and given that terra HAS the patient mars lacks, the answer is not automatically the same as terra's existing practica visibility. STILL OPEN AND NOW ROUTED TO THE CLIENT, not to a peer: (1) does the x-ray live as long as the practica or does it have a retention period; (2) does deleting or archiving the practica reclaim the file from api-images or only drop the local reference; (3) who may view it. Nothing in the build depends on these until the delete path and the access check get written, so the upload and storage work proceeds ahead of the answers.
-
RETENTION, DELETION AND VISIBILITY DECIDED BY ELAZAR 2026-09-05, answering the three questions mars could not supply because mars never ruled on any of them and has no patient identity to rule about. Marker for this decision: RET-208-20260905. 1. LIFETIME - the x-ray lives with the practica. No separate retention period, no expiry of its own, nothing to schedule. It is a part of the record rather than an attachment with its own clock. 2. DELETION PROPAGATES - deleting the practica DELETES THE FILE from api-images, not just the local reference. This is the point where terra deliberately departs from both peer implementations: mars soft-deletes practicaImages.deletedAt and leaves the object in the store indefinitely, and pluto has no reclaim path for abandoned objects either. Terra owns the reclaim, and there are two consequences to hold. First, terra's practicas are SOFT-deleted with archive triggers - nothing here hard-deletes a row - so "deleting the practica" has to be given a precise trigger point: the DELETE against api-images fires when the practica is archived/soft-deleted, and it is not reversible, so an archived practica cannot later show its x-rays again. That is the cost of a real reclaim and it should be stated in the UI before someone archives. Second, the reclaim call is an HTTP DELETE and can fail while the local transaction succeeds, so the reference row must record enough to retry: keep the imageId on the archived row rather than blanking it, and treat a failed reclaim as a retryable item, not a swallowed error. Mars's reclaim path exists (its image-api.ts ~line 869) but fires only on explicit user-driven deletion of an image, so it is a call to copy, not a behaviour. 3. VISIBILITY - docentes, and everyone NAMED IN THE PRACTICA. Two notes on making that predicate real. Mars's equivalent is owner OR fullAccess/viewAllPracticas capability OR a viewStudents-capable user who is a teacher SPECIFICALLY for that comision, and the useful part of that is the specificity: teacher-for-this-thing, never merely adjacent to it. THE DEPENDENCY THAT MATTERS: "everyone named in the practica" is not currently expressible in terra, because one practica names exactly ONE person today - the loader, with their own rol - and multi-participant entry is TERRA-140, which is blocked on client answers about writing a record on another person's behalf. So this rule reduces today to loader plus docentes, and it becomes the rule as stated only once TERRA-140 lands. That is a real coupling between the two rows and not a detail: an x-ray visible to "the team" is meaningless while the team is not recorded.
-
TABLE SHAPE AGREED with db-terra-cc 2026-09-05, migration not yet drafted. Marker: SHP-208-20260905. CHILD TABLE, not a column on the parent. One row per image on a child of terraPracticas, following mars's practicaImages rather than pluto's model - pluto keeps the image id ON the users row, which cannot survive N x-rays per practica. Carries the api-images id as text alongside mime, sizeBytes, checksum, width, height, label, sortOrder and uploadedBy, with an archive twin so the mapping is soft-deleted and never hard-deleted, and partial indexes on the parent keys WHERE deletedAt IS NULL. TOOTH KEY: FK to terraPracticasPiezasDentarias.id, the surrogate id from the terra_14 swap - NOT codigoFdi. db-terra's reason is the right one: codigoFdi is text on a SHARED table and names a tooth in general, not THIS practica's pieza row, and terra_14 already left the active-view pattern in place for exactly this join (vTerraPracticasPiezasDentarias). So an x-ray attaches to the pieza as recorded on this practica, which is what per-pieza radiography actually means. RECLAIM IS A COLUMN, NOT A QUEUE - single writer, so no separate worker table. On the archive twin: reclaimStatus text CHECK IN (pending, done, failed) defaulting pending, plus reclaimAttemptedAt timestamptz, and the archive row KEEPS imageId always rather than blanking it. That is what makes Elazar's deletion ruling enforceable instead of aspirational: mars and pluto both leave the object in api-images forever precisely because nothing records that a reclaim is owed. THE OPEN OPERATIONAL POINT, and it is mine to answer, not db's: db-terra describes a reclaim job polling WHERE reclaimStatus != done, and TERRA HAS NO SUCH RUNNER. There is no worker, no cron and no queue in this app today. So the reclaim needs a concrete trigger before the column means anything - the archive path attempting the DELETE inline and recording the outcome, with a sweep for the failures, is the shape that needs no infrastructure that does not exist. Filed here so the column does not land as a promise nobody keeps.
-
MIGRATION DRAFTED, db/migrations/draft/terra_208_practica_images.sql, db-terra-cc 2026-09-05, dry-run v2 14/14 arms pass. NOT APPLIED - draft only, and that distinction matters on this project: terra_56 was reported as landed the same morning and my own information_schema query showed none of its four columns existed. Verify before wiring, never on the word. Marker: DDL-208-20260905. SHAPE AS DRAFTED: practicaId, practicaToothId as a nullable FK to terraPracticasPiezasDentarias.id, imageId text, then mime, sizeBytes, checksum, width, height, label, sortOrder, uploadedBy, plus reclaimStatus and reclaimAttemptedAt. Reuses all six existing shared trigger functions and adds ZERO new ones - which is the part copying mars would have got wrong. NO ARCHIVE TWIN, correcting my own earlier spec on this row: terra does not use mars's per-table twin pattern. It already has ONE generic terraPracticasArchive (archiveSeq / rowId / rowBefore / rowAfter jsonb) fed by fn_terraDetailArchive on every detail table's soft-delete, and practicaImages follows that. The reclaim columns therefore live on practicaImages itself. That is better for the reclaim rather than merely equivalent: the sweep is a query over the LIVE table - deletedAt IS NOT NULL AND reclaimStatus <> 'done' - so the soft-deleted row is both tombstone and work item, and a jsonb snapshot could never have served as the work item since you cannot index a queue out of rowBefore. THE CROSS-PRACTICA TOOTH HOLE, and the reason it is worth recording rather than just fixing. The v1 draft had no constraint tying practicaToothId to its own practicaId, so a tooth row belonging to a DIFFERENT practica was silently accepted, and the proposal was that I validate the pairing in the server action. Refused, because that is precisely the shape this project has already recorded twice: TERRA-130 (docente-controlador membership checked only in the action and therefore not a guarantee) and TERRA-136 the same finding again, with practica-actions.ts carrying the TERRA-131 comment stating outright that the live path INSERTs directly and the boundary function is never called. An action-only check means the next write path - a backfill, a second form, an importer, hand SQL - attaches an x-ray to someone else's tooth and nothing notices. FIXED IN THE DDL: UNIQUE (id, practicaId) on terraPracticasPiezasDentarias, which costs nothing since id is already unique and rejects no existing row, plus a composite FK on practicaImages (practicaToothId, practicaId) referencing it, MATCH SIMPLE so a NULL tooth - the per-practica x-ray case - is unconstrained. Cross-practica now raises foreign_key_violation. The app will still validate it for the ERROR MESSAGE; it is no longer what makes it true. GUC NAMES, correcting a stale memory: the archive context GUCs are app.terraUserId and app.terraArchiveReason, terra-prefixed, confirmed live. A memory file calling that rename "pending" is out of date. Nothing shipped on the wrong name. STILL OPEN, with Elazar via pm: what runs the sweep. Terra has no cron, no worker and no scheduled route - vercel.json has no crons block and src/app/api holds only health. The archive path attempts the api-images DELETE inline and records the outcome, so the common case needs no infrastructure; only the failures need a retry surface. Columns are correct either way, so this gates nothing.
-
db-terra-cc / db
-
Draft written: db/migrations/draft/terra_208_practica_images.sql. Reuses terra's 6 existing generic archive/guard trigger functions (no new PL/pgSQL), composite FK (practicaToothId,practicaId)->terraPracticasPiezasDentarias(id,practicaId) enforcing tooth belongs to the same practica, reclaimStatus/reclaimAttemptedAt columns for the api-images deletion-propagation ruling. Dry-run 14/14 arms pass. Sent to coder02-terra-cc for app-side wiring, db02-terra-cc review pending. Open non-DDL item: no cron/worker/scheduled route exists in terra for the reclaim-failure sweep - routed to Elazar/pm as a sub-decision, does not block this migration.
-
App side completo y pusheado (v0.31.45). Las radiografias se adjuntan al cargar la practica y quedan listadas en la ficha. Es el primer punto de la aplicacion que usa el servicio de imagenes: hasta ahora era una restriccion escrita, con la clave configurada y leida por nada mas que un script de prueba. Las imagenes se suben despues de validar y ANTES de abrir la transaccion, con los identificadores ya en mano. El orden importa: subir primero y validar despues deja archivos en el servicio sin nada que los referencie cuando la carga se rechaza, y no hay barrido que los recupere. Cada imagen queda atada a la fila de pieza de ESTA practica, resuelta dentro de la transaccion, nunca al codigo de la pieza: el codigo nombra un diente en general, no la fila de esta practica. La ficha las LISTA, no las muestra. Todos los accesos al servicio piden la clave y no hay ruta publica, asi que mostrar la imagen requiere una ruta del servidor que la sostenga. Decir que hay una radiografia adjunta es lo honesto hasta que esa ruta exista. Dos cosas siguen sin construir y no por olvido: la aplicacion no tiene camino de borrado ni de archivado de una practica, asi que la regla de que borrar la practica borra el archivo no tiene todavia donde dispararse; y no hay tarea programada, asi que el barrido de recuperacion no tiene quien lo corra. Esa segunda queda pendiente de decision. 477 tests en verde, tsc limpio.
-
db02-terra-ca
-
db-terra-cc
-
db02-terra-ca / db
-
HANDOFF db-terra-cc -> db02-terra-ca, orden directa de Elazar en CLI 2026-09-10. Marker HOFF-208-mtvfq. CORRECCION MEDIDA AHORA, el spec de 2026-09-05 en esta misma fila dice 'NOT APPLIED - draft only' y ESO YA NO ES CIERTO: terra_208 esta APLICADO EN VIVO. Verificado en pg_class: tabla practicaImages, vista vPracticaImages, indices ix_practicaImages_practicaId_live / practicaToothId_live / reclaimPending / uploadedBy, y el FK compuesto practicaImages_tooth_practica_fk (practicaToothId, practicaId) -> terraPracticasPiezasDentarias(id, practicaId) existe. El archivo esta en db/migrations/applied/terra_208_practica_images.sql, no en draft/. No verifiques sobre la palabra de esta fila: verifica en information_schema, que es lo que la propia fila advierte sobre terra_56. LO QUE SIGUE ABIERTO y no es DDL: terra no tiene cron, worker ni ruta programada, asi que el barrido de reclaim de imagenes huerfanas no tiene quien lo corra; es decision de Elazar/pm, no bloquea nada aplicado.
-
X-ray DB schema is live; remaining image behavior and reclaim-runner decisions are non-DDL
-
was: Elazar GO 2026-09-05, from TERRA-143 item 5: "we need x-rays and user profile pictures". This row carries the x-ray half only. Profile photo is NOT here - it lands on the shared users record, it is already promised in the mi-perfil UI text, and it is enamel_004 in someone else's lane; Elazar ruled leave it there. WHAT DOES NOT EXIST TODAY, and it is more than a form control. pm/02-architecture.md:72 states it as a constraint rather than a description: images go to the shared api-images service, never Supabase Storage, NOT BUILT. Terra has no api-images call site at all - IMAGE_API_KEY is set in Vercel and read by nothing except scripts/smoke-image-api.ts. There is no table, no route, no component, and no column anywhere that holds an image reference. So this is a subsystem, not a field, and it is comparable in size to TERRA-112 rather than to the four form fields decided alongside it. THE FOUR PIECES. (1) A server action that POSTs to https://api.pensanta.com/mars/ with terra's key. Three things about that URL read as broken when they are correct, and they are already written down in the smoke script: there is no /terra path - /mars is just the directory the service occupies and TENANT RESOLVES FROM THE X-API-Key HEADER, never the path; the POST needs the TRAILING SLASH or Apache mod_dir 301s it and the redirect downgrades POST to GET, which surfaces as a 404 NOT_FOUND and is a routing artefact, not an auth failure; and a browser hit returning 401 AUTH_FAILED is correct behaviour, not an outage. (2) A terra-side table mapping practicaId to the api-images id, with the same archive and no-hard-delete triggers the other child tables carry. THAT IS DB-TERRA'S LANE, a migration, not mine - terra enforces cardinality in the database and not in the Node action, and shipping the form against a permissive table is the TERRA-136 shape already recorded on this project. (3) The upload control on the practica form. (4) The pre-save question below. THE PRE-SAVE PROBLEM IS THE DESIGN, and it is why this is not a copy of any existing form field. The practica row is created on submit, so an x-ray picked before that has no practicaId to hang on. Three shapes, none free: upload only after save, which changes the operador's workflow from one pass to two; hold the bytes client-side and upload inside the same server action as the insert, which puts a large multipart payload inside a database transaction; or upload immediately as an orphan and reconcile, which needs a sweeper for the ones nobody ever claims. NOT DECIDING THIS BY ARGUMENT: asked coder-mars-cc and coder-pluto-cc 2026-09-05 for their actual call sites, since both already ship against api-images and one of them has already paid for whichever shape they run. Maxilo is a second reference for the same question, same as it is for TERRA-140. CREDENTIAL NOTE, not a blocker but it bites on first local test: IMAGE_API_KEY is marked SENSITIVE in Vercel, so vercel env pull writes it EMPTY. That is expected and is not a misconfiguration. It comes from coder-apiimages-cc by llmmsg DM. Do not go looking for it in a tracked file and do not rotate anything over its absence. STILL OPEN, and neither is mine to settle: the retention and visibility rule for a clinical image of a named patient - the default is the standing terra PII rule, detail page only and never in a list, table, export or log, but an x-ray is a heavier object than a phone number and the client may want it stricter; and whether this is on the 1/10 demo path or off it. Filed unblocked per Elazar 2026-09-05 (option 1b), so work starts on the parts that do not depend on those two.
-
DB schema verified live; non-DDL behavior and reclaim-runner decision remain.
-
was: DB-side status corrected 2026-09-10. Live catalog contains practicaImages (table), vPracticaImages (view), ix_practicaImages_practicaId_live, ix_practicaImages_practicaToothId_live, ix_practicaImages_reclaimPending, ix_practicaImages_uploadedBy, and composite FK practicaImages_tooth_practica_fk (practicaToothId, practicaId). Matching source is db/migrations/applied/terra_208_practica_images.sql.\n\nThe earlier draft-only/no-table claim is stale. Boundary: this verifies schema only, not image upload/display/reclaim behavior. The absent scheduled reclaim runner is an operational/product decision, not a pending DDL prerequisite. Do not re-apply the migration.
-
X-ray schema is live and authenticated detail display is implemented; ship/exercise and reclaim policy remain
-
coder02-terra-ca / coder
-
Current-source regression pins next/image unoptimized so the browser, not the cookie-less optimizer, requests the authenticated proxy. Focused 4/4, typecheck and scoped lint PASS after audit feedback.
-
Independent audit PASS 2026-09-10 (audit-terra-ca, tag audit-terra-ca-mtvnp4fj4iqj) at current source/BWI boundary. Excludes commit, deploy/PTD, authenticated-browser/live-email, concurrent-drain, and rerun of claimed 509-test gate.
-
Scoped source shipped in d3ea382 as v0.31.50; Vercel production-target deployment dpl_GKQxq7iwvYsxX2x4DEd3V4MqWuJs is building. Runtime and authenticated image exercise remain pending.
-
was: ESTADO VERIFICADO 2026-09-10. DB VIVA: practicaImages, vPracticaImages, indices de acceso/reclaim y FK compuesta ya existen; no re-aplicar terra_208. CODIGO LOCAL NO SHIPPED: la ficha autenticada de practica muestra radiografias mediante /api/practica-image/[id]. La ruta renueva sesion, exige PRACTICA_ROLES, vuelve a aplicar el predicado SQL exacto de acceso a la practica, valida el UUID, oculta existencia con 404, consulta api-images solo despues de autorizar, limita MIME a JPEG/PNG/WebP y pone private,no-store + nosniff en respuestas de exito y error. Pruebas de handler cubren auth negada, ID invalido, fila no autorizada, MIME rechazado y upstream caido. EVIDENCIA: focales 58/58, typecheck PASS, suite 509/509, lint 0 errores y 3 warnings preexistentes; auditor PASS en frontera de autorizacion y pidio la correccion de headers/pruebas, ya aplicada y reenviada para veredicto final. NO SHIPPED/NO EJERCITADO: falta commit/deploy/PTD y navegador autenticado. El barrido de imagenes huerfanas sigue siendo decision operativa/producto, no DDL pendiente.
-
Implementacion, DB y deploy completos; falta ejercicio autenticado con imagen real y decision humana sobre reclaim de imagenes huerfanas.
-
Radiografias: schema y display desplegados; falta ejercicio autenticado y politica de reclaim
-
coder-terra-cc
-
EL BRAZO DE IMAGEN NO SE PUEDE EJERCER HOY: una foto de telefono es rechazada antes de llegar a nuestro codigo. Marker: T218-block-TERRA-208-mtwxt2. TERRA-218 (p1, hija de TERRA-143) documenta el defecto completo. Lo esencial para esta fila: el cuerpo de una server action de Next tiene un limite por defecto de 1 MB - campos del formulario mas TODAS las radiografias juntas - y next.config.ts no define serverActions.bodySizeLimit, asi que ese default rige. Pasado el limite el framework devuelve 413 antes de que corra una linea nuestra. El techo duro de Vercel es 4.5 MB (FUNCTION_PAYLOAD_TOO_LARGE), verificado hoy en su documentacion, asi que subir el limite no alcanza: una foto de 5 MB o dos de 3 MB siguen fallando. POR QUE IMPORTA PARA EL EJERCICIO Y NO SOLO PARA EL BACKLOG: practica-form.tsx declara accept='image/*', que en un telefono abre la camara, y una foto de telefono pesa 3-6 MB. O sea que la forma MAS PROBABLE de ejercer este brazo - alguien fotografiando una radiografia con el celular - falla. Y falla mostrando 'Ocurrio un error inesperado. Por favor, intenta de nuevo mas tarde.', el catch generico de use-action-form.ts:72, identico al texto que se veria si el servidor estuviera caido. POR ESO SE ESCRIBE ACA Y NO SOLO EN TERRA-218: si Elazar intenta el ejercicio antes de que TERRA-218 este arreglado, va a ver un error generico y lo va a leer como una caida de la aplicacion. No lo es. Es un limite de tamaño sin mensaje propio. QUE SI FUNCIONARIA HOY: una imagen de menos de 1 MB en total contando el resto del formulario - una captura de pantalla chica o un JPEG comprimido a proposito. Sirve para probar el camino de api-images de punta a punta, NO para probar el caso real de uso. Nunca se detecto antes porque la superficie esta desplegada desde 0.31.50 y nunca fue ejercida: practicaImages tiene 0 filas, medido por db-terra-cc.
-
coder-terra-cc 2026-09-12: the blocker on this row is NOT the missing authenticated exercise, and a test user in hand does not move it. The real blocker is TERRA-218 - a Next server action body defaults to 1 MB, next.config.ts does not set serverActions.bodySizeLimit, and Vercel's hard ceiling is 4.5 MB. practica-form declares accept='image/*', which opens the phone camera, and a phone photo is 3-6 MB. So the real case 413s BEFORE our code runs, whoever is logged in, and surfaces as the generic catch text - indistinguishable from the app being down. A sub-1MB test image would exercise api-images end to end but would NOT exercise the case this row is about, so a green run on a small file is a false pass. Fix TERRA-218 first; until then this row stays blocked regardless of session capability.
-
MARKER-t208-upload-fixed. 313b19e pushed, awaiting deploy (discriminator is /api/health deploymentId, not version - version last bumped 2026-09-22). Two independent defects, either one sufficient to make an upload impossible: 1. uploadImage sent only `file`. api-images requires file+width+height+checksum, answers 400 INVALID_METADATA on any missing one, and runs that check BEFORE the format check - so the absent fields masked every other error the box could report, and terra`s upload path had never once succeeded in production. First exercised 2026-09-28 10:26Z by a real user (TERRA-250); failed on the first attempt. Accepted set measured on the box by coder-apiimages-cc: JPEG only, <=10MB, any dimensions >0. 2. downscale-image.ts returned a non-JPEG unchanged when it was under the size-skip threshold. api-images is JPEG-only and content-sniffs, so this made SMALL PNGs fail while large ones succeeded - only the large ones reached the re-encode. src/lib/jpeg-dimensions.ts (new) reads the frame dimensions out of the JPEG header: the browser knows them but uploadImage runs in a server action and receives only the File, and recovering them from the bytes means the stored value describes what was actually received rather than what a client claimed. A non-JPEG returns null and the upload is refused with a message naming the problem, instead of as a remote 400. The checksum is computed over the bytes being sent. The live box echoes it back without verifying it, so it is NOT an integrity check today - it is a REQUIRED field, and the value that will still match when the server-computed sha256 ships. 10 tests pin the marker walk byte by byte rather than against one sample file: these numbers become what api-images stores and the box echoes what it is told, so a wrong dimension is not visibly wrong anywhere. Covers SOF0/SOF2, the DHT-in-the-0xFFCn-range trap, 0xFF padding runs, PNG, truncation, zero dimensions, a corrupt segment length, and stopping at SOS. Still open on TERRA-208: the reclaim sweep runner has never been run with --apply, and the 0-live-vs-16-trash reconciliation is its precondition.
-
MARKER-t208-first-real-upload-mul4n. THE IMAGE ARM IS EXERCISED. First successful practica-with-radiografia in terra history, driven by proxy-general-cc on Elazar's live session against dpl_13Sb51EnAoAAtayoCqJPJVz6qNW8 (commit 32cd167), 2026-09-28 ~10:51 ART. Práctica N° 7, https://terra-enamel.pensanta.com/practicas/a7e55490-e820-4e86-aae1-eb3b52a0c8ab. Paciente Synthetic TERRA208, DNI 90000001. Archivo 2026-09-28-test-xray-panoramic.jpg, 500x238, radiografía panorámica real, no el icono de 24x24 del intento fallido. Renderiza como miniatura viva en el detalle bajo Radiografías. practicaImages pasó de 0 filas a 1: ese conteo era la medición que faltaba. QUÉ QUEDA PROBADO: la ruta de subida completa - readJpegDimensions lee el SOF, el checksum sha256 se calcula sobre los bytes enviados, los cuatro campos que api-images exige (file/width/height/checksum) llegan, el 201 vuelve con id, la fila se escribe, y la lectura posterior sirve los bytes por fetchImage. Antes de 313b19e esta ruta nunca había tenido éxito en producción ni una sola vez. QUÉ NO QUEDA PROBADO, y no es una formalidad: (1) el envoltorio de TERRA-250 sigue siendo línea verde - un éxito no ejercita ninguna de las tres causas nuevas (ImageUploadFailed / ImageUploadRefused / ImageApiKeyMissing), y solo un fallo real en producción las pondrá en rojo; los tests unitarios son el control forzado. (2) El brazo del re-encode de downscale-image NO se ejerció: la fuente ya era JPEG, así que la rama que arregla el PNG chico sigue sin verse correr contra producción. (3) No hubo toast de éxito - el submit redirigió directo a /administrar y proxy encontró la práctica por el listado; eso concuerda con la prohibición de toasts de éxito de la flota, no es un defecto, pero significa que la confirmación al usuario en esta ruta es la redirección misma. COLA QUE ESTO CREA: practicaImages ya no está vacío, así que la reconciliación 0-vivas-contra-16-en-_trash que es precondición del primer --apply de scripts/reclaim-practica-images.ts ahora tiene una fila viva que contar. Rehacer ese censo antes de correr el barrido; el número anterior está vencido.
-
MARKER-t208-serve-500-mul4v. CORRECCION AL EVENTO ANTERIOR (MARKER-t208-first-real-upload-mul4n): NO era una miniatura viva. Elazar lo detecto; proxy-general-cc lo confirmo y rastreo. Es el icono de imagen rota del navegador. Yo registre "renderiza como miniatura viva" copiando el primer reporte de proxy sin control independiente - la pantalla mostraba el nombre del archivo, y eso no distingue una imagen servida de una fallida. QUE SIGUE SIENDO CIERTO: la SUBIDA funciona. La fila existe, practicaImages paso de 0 a 1, el id resuelve. 313b19e esta ejercitado. QUE ES FALSO: que la imagen se vea. GET /api/practica-image/2026_f567f9a1-5bd2-42ce-9004-7c274d6b4408 devuelve HTTP 500, 0 bytes, sin content-type. Subida y recuperacion son dos defectos distintos. EL 500 ERA INATRIBUIBLE POR CONSTRUCCION, y esa es la parte que importa. handlePracticaImage responde con cuerpo JSON en TODOS sus caminos (404 NOT_FOUND, 502 IMAGE_PROXY_FAILED). Una respuesta VACIA no puede venir de ninguno de ellos: solo puede venir de un throw que escapa la funcion. Y los dos await mas riesgosos - resolveUserId, que toca la sesion, y authorize, que corre una consulta al pool - estaban FUERA de todo try. Next respondio con su propio 500 vacio y la causa se perdio en el borde. Misma clase de defecto que TERRA-250. ff264f1: ambos quedan atrapados, responden 500 IMAGE_ROUTE_FAILED CON cuerpo, y registran la ETAPA. La etapa separa falla de sesion de falla de base de datos; un solo codigo mandaria al proximo lector al sistema equivocado. console.error y no appEvents: la falla puede SER la base. ESTO NO ARREGLA EL 500. Lo vuelve diagnosticable. La proxima peticion dice que etapa lanza. IMAGE_ROUTE_FAILED = nunca llego a api-images; IMAGE_PROXY_FAILED = llego y fallo el fetch. 10 tests nuevos en practica-image-route-core.test.ts; el archivo no tenia ninguno. Incluyen que el id con prefijo de anio (2026_<uuid>) es aceptado - un regex que solo aceptara uuid pelado habria 404-eado una imagen valida.
-
MARKER-t208-42p18-mul5g. Image-serve 500 root-caused and fixed, pushed 66726d2. Cause: the route borrowed PRACTICA_ACCESS_SQL and substituted pi."practicaId" for $1. That left $1 referenced NOWHERE in the statement while the route kept passing a value for it. Postgres infers a parameter's type from where it is used; a parameter used nowhere has no type to infer, so the parse failed with 42P18 'could not determine data type of parameter $1' before a single row was read. Evidence, not inference: reproduced locally against Postgres 17.11 in a throwaway schema with the real table shapes. OLD (shipped) form -> 42P18. NEW (renumbered) form -> OK, rows: 0. Same connection, same session. Schema dropped after. Why it was invisible: the throw was inside authorize(), which sat outside the route handler's only try, so it escaped as an empty 500 - no body, no content-type, nothing in appEvents. That is why the first radiografia terra ever stored uploaded fine and would not display. Fix: placeholders renumbered contiguous ($1 userId, $2 appKey, $3 imageId) and moved out of the route into src/lib/practica-image-sql.ts so the invariant is asserted as a VALUE, not as route source text. 5 new tests pin it, including a control on the helper itself. practica-image-route-core.ts now wraps resolveUserId and authorize in their own try blocks and logs the stage via console.error (not appEvents - the fault may BE the database). Not yet verified: no signed-in fetch against the live deploy. proxy-general-cc asked to fetch /api/practica-image/2026_f567f9a1-5bd2-42ce-9004-7c274d6b4408 once 66726d2 is the live deploymentId. Row stays open until a 200 image/jpeg is observed.
-
MARKER-t208-serve-green-mul5i. VERIFIED LIVE. proxy-general-cc, signed-in session against production after 66726d2: GET /api/practica-image/2026_f567f9a1-5bd2-42ce-9004-7c274d6b4408 -> 200, image/jpeg, 22050 bytes, and the Práctica N° 7 detail page renders the actual panoramic x-ray thumbnail. They checked it visually rather than reporting from the status line, which is the correction to the earlier false green on this same row. Both halves of the path are now green on evidence, not on reading the code: upload (practicaImages 0 -> 1, the first radiografía terra has ever stored) and serve (200 image/jpeg, renders). One caveat proxy named and it is worth keeping: they could not gate the check on deploymentId matching 66726d2, because the deploymentId is not derived from the commit and changed three times while they watched. So the proof here is the RESPONSE BODY, not a deploy identity - a 200 with 22 KB of JPEG cannot come from the 42P18 build, which returned an empty 500 before reading a row. That is the stronger evidence anyway. Remaining on this row before it closes: scripts/reclaim-practica-images.ts has never run with --apply, and its 0-live-vs-16-trash reconciliation is now STALE - there is a live row. Re-run the reconciliation before the first apply.
-
MARKER-t208-reclaim-mul6g. RECONCILIATION RE-RUN, and it closes the last open item on this row. db-terra-ca against the live terra ref: practicaImages holds 3 live rows (deletedAt IS NULL), 0 soft-deleted rows, reclaimStatus distribution {pending: 3}. The reclaim script's queue is therefore 0 - its predicate requires deletedAt IS NOT NULL, so three live pending rows are not sweep candidates. The first --apply has nothing to apply, which is the correct state and not a failure to find work. The stale figure this replaces was '0 live vs 16 trash'. Both halves moved: there are now 3 live rows (proxy's TERRA-207/208 exercises), and the reconciliation no longer reads zero. A GAP WORTH NAMING, not blocking: the 16 objects in api-images trash have no practicaImages row at all, and the script's queue is keyed on deletedAt IS NOT NULL - a row that exists and was soft-deleted. An orphan BLOB with no row was never in the script's reach and re-running the reconciliation does not bring it into reach. Reclaiming those is a different mechanism than this script, and nothing about today's numbers advances it. Also measured: has_table_privilege('db_terra_cc_ro','public."practicaImages"','SELECT') = false. The role exists with LOGIN true but cannot read the table, so the coding lane still has no path of its own to these counts and this reconciliation had to come through db-terra-ca. That is the open dependency, not this row. Both halves of TERRA-208 are green on live evidence: upload (practicaImages 0 -> 3) and serve (200 image/jpeg, thumbnail renders, confirmed visually and by fetch). Reclaim policy is settled: queue empty, nothing to sweep.
-
cleared: Implementacion, DB y deploy completos; falta ejercicio autenticado con imagen real y decision humana sobre reclaim de imagenes huerfanas.
-
Both halves proven on live production evidence. Upload: practicaImages 0 -> 3 live rows, the first radiografías terra has ever stored. Serve: GET /api/practica-image/<id> returns 200 image/jpeg and the detail page renders the thumbnail, confirmed visually and by fetch across four separate exercises. Root cause of the serve 500 was a 42P18 placeholder gap, fixed in 66726d2 and reproduced locally against Postgres 17.11 with a passing control before the fix was accepted. Reclaim policy settled: live reconciliation shows 3 live / 0 soft-deleted / reclaimStatus {pending:3}, so the sweep queue is 0 and the first --apply has nothing to apply. The api-images trash orphans are out of that script's reach by construction (its predicate needs deletedAt IS NOT NULL on an existing row) and are a separate mechanism, recorded on the row, not carried by it.
-
MARKER-mul95a-live-proof: TERRA-208's image-serve fix is now PROVEN live in production, not inferred. /api/health at 12:55 ART returns commitSha bebe59beb8053fb55084ca7db27b864f26f3898e on deploymentId dpl_GPnmtsX6QgUiVakUhk4ofHvoaAn3, and git merge-base --is-ancestor confirms 66726d2 (the 42P18 placeholder renumbering in src/lib/practica-image-sql.ts) is an ancestor of that commit. Until bebe59b this was unanswerable from outside the Vercel account: version reads 0.31.86 at 66726d2 AND at its parent and has not moved since, so the health probe could not distinguish a build carrying the fix from one predating it. bebe59b added the commitSha field for exactly this. COVERAGE STATEMENT: what is proven is that the corrected SQL is in the running build. What is NOT exercised is an authenticated upload and render against that build - no real Google session has touched the image path, so 'images uploading AND rendering' remains unverified by measurement. That exercise is TERRA-213's single real login, and it is Elazar's to perform.