# Auditoría técnica completa — Plataforma Kynda

**Repositorio:** `itproviders-netizen/kynda-poc` · **Fecha:** 5 de agosto de 2026
**Alcance:** 17.596 líneas TS/TSX (117 ficheros), 5.046 líneas SQL (36 migraciones), 3.268 líneas de tests
**Método:** 9 auditorías especializadas en paralelo (seguridad/RLS, dominio y autorización, frontend, IA/KPIs, rendimiento, corrección, accesibilidad, mantenibilidad, CI/CD). **Todos los hallazgos críticos han sido verificados leyendo el código directamente**, no delegados.

---

## 1. Veredicto

La plataforma tiene una **base arquitectónica buena y un núcleo de cadenas QR excelente**, sostenidos por una suite de tests genuina. Sobre esa base hay **una capa de aplicación con agujeros de autorización graves**, un **bug de datos que invalida el reporting** y la **ausencia total del diferencial de producto** (IA, contenido generado, white-label).

No es un proyecto que haya que tirar. Es un proyecto al que le falta el 30 % más difícil y le sobran riesgos que hoy son explotables.

| Dimensión | Nota | Comentario |
|---|---|---|
| Modelo de datos y cadenas QR | 9/10 | Invariantes en BD, RPC serializado. Lo mejor del repo |
| Suite de tests | 8/10 | 57 integración + 18 e2e reales contra BD |
| Cobertura funcional construida | 7/10 | Flujos de usuario y admin completos |
| Documentación en código | 7/10 | Comentarios que explican el *porqué* |
| **Autorización en la capa de app** | **2/10** | 6 vías de escalada/fuga verificadas |
| **Corrección de datos (KPIs)** | **1/10** | Reporting silenciosamente falso a escala |
| **Accesibilidad** | **2/10** | Barreras WCAG nivel A en todos los formularios |
| **Rendimiento a escala** | **3/10** | Se rompe a partir de ~1.000 evidencias |
| IA / contenido / white-label | 0/10 | No existe |

---

## 2. Los cinco puntos débiles que importan

### 2.1 🔴 Cualquier usuario puede aprobar evidencias de cualquier marca

`apps/web/app/(ops)/actions.ts:23, 91, 135`

Las tres funciones que deciden el destino de una evidencia — `approveEvidence`, `rejectEvidence`, `requestClarification` — **no comprueban ningún rol**. Usan el cliente `service_role` (que se salta la seguridad de la base de datos) y solo leen la sesión para rellenar el campo `reviewer_id`, aceptando incluso `null`.

El control de acceso está en `(ops)/layout.tsx`, que protege **la página**, no el endpoint. Una server action de Next es un endpoint HTTP invocable directamente con un POST.

Lo demoledor es que **el propio código sabe que esto importa**. En `apps/web/app/(app)/actions.ts:10-12` está escrito:

> *"Las páginas ya comprueban el estado, pero un server action es un endpoint invocable por su cuenta (pestaña obsoleta, POST directo), así que la guarda de verdad va aquí."*

Y en el panel de marca, `requireBrandAdmin()` se aplica sistemáticamente en las 21 acciones. El agujero está **exclusivamente** en Ops: se sabía cómo hacerlo y ahí no se hizo.

**Consecuencia:** un empleado cualquiera aprueba sus propias evidencias y las de otras empresas, dispara el avance de cadenas ajenas y contamina los KPIs de impacto de terceros.

### 2.2 🔴 Un usuario puede auto-aprobarse las evidencias por base de datos

`supabase/migrations/20260526000100_rls.sql:215-236` + `20260526000000_schema.sql:396-422`

Las políticas `execution_self_update` y `evidence_self_update` autorizan al dueño de la fila a modificarla:

```sql
using (user_id = auth.uid() or kynda.is_staff())
with check (user_id = auth.uid() or kynda.is_staff())
```

Y el guard de la máquina de estados valida que la transición sea **legal**, pero no **quién** la hace: permite `IN_VALIDATION → APPROVED` sin comprobar rol.

La clave anónima es `NEXT_PUBLIC_SUPABASE_ANON_KEY` — pública en el navegador — así que el endpoint PostgREST es alcanzable **sin pasar por la aplicación**. Un `UPDATE evidence SET status='APPROVED'` directo basta.

**Consecuencia:** en una plataforma cuyo producto es el *impacto verificado* para reporting ESRS, cualquier participante puede fabricarse impacto certificado. Es el riesgo reputacional más alto del sistema: greenwashing a voluntad, con trazabilidad falsa.

### 2.3 🔴 Los KPIs devuelven cifras falsas sin avisar

`packages/lib/src/kpi/aggregate.ts:142-145` y `:318-321` + `supabase/config.toml:16`

La consulta que alimenta todo el reporting de impacto pide **todas las evidencias aprobadas de todas las marcas**, sin filtro de marca ni de campaña, y filtra por campaña después en JavaScript:

```ts
.from('evidence')
.select('submitted_at, input_data, action_execution:...')
.eq('status', 'APPROVED');        // ← sin brand_id, sin campaign_id
```

Y `supabase/config.toml:16` fija `max_rows = 1000`. No hay ni un solo `.range()` en el proyecto que controle el truncamiento.

**Consecuencia:** a partir de 1.000 evidencias aprobadas **en el conjunto de la plataforma**, PostgREST trunca la respuesta y los KPIs empiezan a devolver cifras por debajo de la realidad — o cero — **sin error, sin log, sin ninguna señal**. Afecta a la pestaña Impacto, al dashboard, a la tira de impacto del inicio y a los CSV de exportación.

Un cliente firmaría un informe ESRS con datos incorrectos y nadie se enteraría. Para un producto que vende reporting auditable, es el fallo más grave del análisis.

### 2.4 🔴 Cinco vías más de fuga entre marcas y de datos corruptos

Verificadas una a una:

| Vía | Fichero | Qué permite |
|---|---|---|
| `submitEvidence` no comprueba inscripción, marca ni ámbito | `(app)/actions.ts:118-143` | Inyectar evidencias en campañas de otra empresa; un no-empleado acotado a una campaña escapa de su ámbito |
| `ensureMembership` resucita membresías `REVOKED` | `c/actions.ts:27-48` | Un usuario **bloqueado por el admin** recupera acceso escaneando el QR. Peor: quien se dio de baja por RGPD vuelve a ser miembro activo, y se le siguen tratando datos sin base legal |
| `kpis.csv` no comprueba la propiedad de la campaña | `.../export/kpis.csv/route.ts:12-17` | Un admin descarga los KPIs de una campaña de otra marca. Su hermano `impacto.csv:33` **sí** hace la comprobación |
| Cola de Ops sin filtro de marca + `BRAND_ADMIN` en `OPS_ROLES` | `(ops)/layout.tsx:6`, `ops/queue/page.tsx:24-30` | Cualquier admin de marca ve nombres, emails y **fotos** (con URL firmada de 1 h) de los voluntarios de las demás |
| `audit_log` acepta INSERT con `with check (true)` + permiso a `anon` | `rls.sql:293` + `20260603130000:22-29` | El registro de auditoría —vendido como garantía RGPD— es **falsificable por cualquiera, incluso sin autenticarse** |

A esto se suma que la migración `20260603130000_fix_public_schema_grants.sql` concede `GRANT ALL` sobre **todas** las tablas y funciones del esquema `public` a `anon` y `authenticated`, y que es **posterior** al `revoke` de `audit_log` (mayo), por lo que lo anula.

### 2.5 🔴 El diferencial del producto no existe

| Prometido en el RFP | Realidad en el código |
|---|---|
| Validación de evidencias con IA (Módulo B) | **Cero dependencias de IA.** Dos botones manuales |
| Motor de contenido con LLM + generative media (Módulo C) | Plantilla de texto fija, etiquetada `stub` en `(ops)/actions.ts:49-69`. Nunca genera imagen |
| Antifraude, detección de duplicados | No existe. `FRAUD_BLOCKED` nunca se asigna |
| KPIs calculados desde la evidencia | Sumas de números **tecleados a mano** por usuario y admin |
| Reporting ESRS automatizado | Catálogo para elegir a mano; el PDF lo envía **Kynda Ops manualmente el día +10** |
| White-label multi-tenant | Logo de Kynda incrustado en las 3 superficies; colores estáticos; la tabla `brand` **no tiene columnas de branding** |

La prueba documental más clara: la tabla **`ai_review_run`** existe en el esquema, con RLS e índices mantenidos, y tiene **cero referencias en todo el código**. La IA se modeló y nunca se construyó. Igual que `notification`, `share_event` y `brand_plan` — cuatro tablas mantenidas para nada.

---

## 3. Puntos débiles estructurales

### 3.1 La regla arquitectónica central está incumplida y documentada como si se cumpliera

El `README.md:44` afirma que `packages/lib` es *"**el único sitio** que habla con la base de datos"*. La realidad: `packages/lib` tiene 1.367 líneas, y `apps/web/lib/db.ts` tiene **1.940 líneas de consultas**, más consultas embebidas en los 9 ficheros de server actions y en varias páginas.

Consecuencia práctica: como casi todo el acceso usa `service_role` (que ignora la seguridad de fila), **la RLS —que está razonablemente bien escrita— casi nunca protege el camino real**. Toda la autorización recae en código de aplicación… que es justo donde están los agujeros del punto 2.

### 3.2 La seguridad de tipos es aparente, no real

- `tsconfig.base.json` es estricto: `strict`, `noUncheckedIndexedAccess`. **Cero `any`, cero `@ts-ignore`.** Impecable sobre el papel.
- Pero **no existen tipos generados de Supabase**. Los ~25 interfaces de `db.ts` están escritos a mano y sostenidos por **50 `as unknown as`**.
- Y `next.config.mjs:22,25` desactiva `ignoreBuildErrors` y `ignoreDuringBuilds`: **ni los errores de tipos ni el lint bloquean un despliegue**.

Renombrar una columna en una migración no produce ni un error de compilación: rompe en producción. Es peor que usar `any`, porque `any` es visible en una revisión de calidad y `as unknown as` aparenta rigor.

Añadido: **no hay ninguna configuración de ESLint en el repositorio**, y el job de CI llamado `typecheck-lint` **no ejecuta lint**.

### 3.3 Operaciones críticas sin transacción ni comprobación de errores

`submitEvidence` (`(app)/actions.ts:109-271`) son 7 pasos sin transacción, y los pasos 4 a 7 **no comprueban el error**:

- Si falla el `insert` en `validation` (línea 238), la evidencia queda enviada, el intento consumido y **nunca aparece en la cola de validación**. La función devuelve `{ ok: true }`. Evidencia huérfana permanente.
- Si falla la subida de una foto a mitad del bucle, la fila de evidencia ya existe: intento gastado, fotos a medias, sin rollback.
- El error del `insert` en `evidence_media` tampoco se comprueba: foto en Storage sin fila en BD → invisible para el revisor **y para la purga RGPD**.
- El consentimiento de uso de imagen se inserta sin verificar: se publican fotos sin registro de consentimiento.

Además, el servidor **no valida ni el tamaño ni el tipo** de los archivos (`contentType: file.type` viene del cliente), y `bodySizeLimit` está en 15 MB cuando **Vercel corta en ~4,5 MB**: un voluntario que suba 2-3 fotos de móvil verá fallar el envío *después* de haber hecho la acción.

### 3.4 Aprobar una evidencia no es idempotente

`(ops)/actions.ts:32-47` y `admin/actions.ts:1364-1398`. El `UPDATE` de `validation` filtra por estados pendientes, pero **su resultado nunca se lee**, y los `UPDATE` de `evidence` y `action_execution` se ejecutan siempre.

Aprobar una evidencia ya rechazada la deja en `APPROVED` sin ninguna fila de validación aprobada. Un doble clic en "Aprobar" produce contenido duplicado.

Y hay **tres implementaciones divergentes** de la misma transacción de negocio: aprobar desde Ops genera contenido social y refresca la vista del usuario; aprobar desde el panel de marca **no hace ninguna de las dos cosas** (`admin/actions.ts:1380` no revalida `/mine`). Es un bug real derivado de la duplicación: el usuario no ve su evidencia aprobada.

### 3.5 Límites de negocio que se muestran pero no se aplican

`max_seats` / plazas por jornada se pintan en la interfaz (`explore/page.tsx:28`) pero **`joinAction` no los comprueba nunca** y no hay restricción en base de datos. Una jornada con 10 plazas admite 200 inscritos. No hay carrera que ganar: el límite simplemente no existe en servidor.

Igual con los campos dinámicos del formulario de evidencias: los `required`/`min`/`max` solo llegan al HTML. El servidor acepta **cualquier clave con cualquier valor** (`(app)/actions.ts:145-154`) y lo suma directamente a los KPIs certificados.

### 3.6 Bug silencioso: las acciones nuevas no heredan la configuración de su blueprint

`apps/web/app/(brand-admin)/admin/actions.ts:621`. Al crear una jornada con "+ Nueva acción", el código busca la acción-plantilla ordenando la tabla `blueprint_action` por una columna que **no existe**: `sort_order`. Esa tabla usa `position` (las tablas hermanas `action`, `action_kpi_def` y `blueprint_action_field` sí tienen `sort_order`, de ahí el desliz). La consulta falla en PostgREST, el error **no se comprueba** (`const { data: ba } = …`), y `blueprintActionId` queda a `null`.

**Consecuencia:** toda acción creada con "+ Nueva acción" en una campaña que tenga blueprint **nunca hereda su `blueprint_action_id`** → pierde la configuración de evidencias y de KPIs que debía heredar de la plantilla, de forma invisible (no hay error para el admin). No rompe la aplicación, pero corrompe la configuración de las campañas. **Arreglo:** cambiar `.order('sort_order')` por `.order('position')` en la línea 621, y comprobar el `error` de la consulta.

*Este hallazgo lo aportó una segunda pasada de auditoría independiente (contraprueba), no el barrido inicial; está verificado contra el esquema (`supabase/migrations/20260526000000_schema.sql:171`).*

---

## 4. Rendimiento: a qué escala se rompe

| Umbral | Qué ocurre |
|---|---|
| **1.000 evidencias aprobadas** (plataforma) | Los KPIs empiezan a mentir en silencio (§2.3) |
| **60 campañas en una marca** | La home de admin dispara ~241 peticiones HTTP por render (`db.ts:489-523`, patrón N+1 de 4 consultas por campaña) → timeouts de Vercel |
| **180 jornadas en una campaña** | Los `.in('action_id', […])` superan el límite de URL de PostgREST → error 500 en participantes, validación, impacto y KPIs |
| **10.000 usuarios** | Falta índice en `brand_membership(user_id)`: escaneo secuencial **en cada petición autenticada**, 4-5 veces por render |
| **50.000 evidencias** | La home de admin transfiere decenas de MB de JSON por render; riesgo de agotar memoria en la función serverless |
| **Purga RGPD con acumulación** | El `.in()` con miles de identificadores excede el límite de URL → **el cron falla en silencio y se incumple la retención legal** |

Otros datos: **cero caché** en todo el proyecto (`unstable_cache`, `revalidate`, `cache()` de React: 0 usos), **cero paginación** en 8 de las 9 listas que crecen, y **35 `<img>` crudos frente a 2 `next/image`, ambos con `unoptimized`** — el revisor descarga la foto original de 4 MB para verla en un recuadro de 100 px.

La corrección con mejor relación esfuerzo/impacto: envolver `getCurrentMemberships`, `getCurrentUser` y `getTenantSlug` en `cache()` de React. **Cuatro líneas** eliminan el 80 % de ese tráfico redundante.

---

## 5. Accesibilidad: barreras de nivel A en toda la aplicación

Recuento verificado sobre los 140 ficheros de interfaz:

| Atributo | Ocurrencias |
|---|---|
| `htmlFor` (asociar etiqueta y campo) | **2** (en un solo componente) |
| `role="alert"` | **0** |
| `aria-live` | **0** |
| `aria-invalid` | **0** |
| `aria-describedby` | **0** |
| `error.tsx` / `not-found.tsx` | **0** |

Las tres barreras de mayor superficie:

1. **Ningún campo de formulario tiene etiqueta asociada.** Ni en el front de empleado (`inputs.tsx:8-16` usa un `<div>`, no un `<label>`) ni en el panel de admin (`admin-ui.tsx:266-288` usa `<label>` sin `htmlFor` y como hermano, no envolviendo). Un lector de pantalla anuncia *"cuadro de edición, en blanco"* en el login, en el registro y en los 35 campos del editor de campañas.

2. **El indicador de foco de teclado es literalmente invisible.** `inputs.tsx:20` elimina el contorno nativo (`outline-none`) y lo sustituye por un halo lima `#d2ff66` que se dibuja sobre el fondo `#efefea`. **Contraste 1,00:1.** Y en los radios y checkboxes, que ocultan el input nativo con `sr-only`, no hay ningún estilo de foco.

3. **Los errores no se anuncian y no se leen.** Ningún contenedor de error tiene `role="alert"`, y el estilo (rojo `#f47564` con texto blanco) da **2,77:1** frente al 4,5:1 exigido. Está replicado en 12 sitios. Es el peor lugar posible para fallar: el mensaje de error es justo lo que el usuario necesita leer para desatascarse.

Y dos bugs funcionales derivados:

- **Las etiquetas de la navegación móvil no se muestran nunca.** `chrome.tsx:67` usa `hidden xs:inline`, pero **el breakpoint `xs` no existe** (no hay `tailwind.config` y `@theme` no lo define; Tailwind v4 no lo trae). El chrome principal de una app mobile-first son tres iconos sin texto.
- **`AppShell` renderiza `{children}` dos veces** (`app-shell.tsx:80,89`): todo el árbol de cada página se monta duplicado, con identificadores repetidos e imágenes descargadas dos veces.

Además: **acciones destructivas sin confirmación** — bloquear a un empleado, quitarle el rol de admin, revocar una invitación y cerrar una campaña (irreversible) son botones de 26 px dentro de una tabla con scroll horizontal, sin ningún paso intermedio ni deshacer.

**Internacionalización: no existe.** El 100 % de los textos están incrustados en el código (~990 literales en 93 ficheros, más las plantillas de email). El RFP pedía *"ES inicialmente con capacidad de ampliación a EN sin reescritura"*. **Ese requisito no se cumple**: añadir inglés hoy cuesta 3-5 semanas-persona, y el coste crece con cada sprint.

---

## 6. Mantenibilidad

**Tres ficheros concentran el riesgo** (4.751 líneas, casi un tercio de la aplicación):

- `apps/web/lib/db.ts` (1.940) — módulo-Dios: mezcla resolución de tenant, autorización, catálogos, CRUD, cola de validación, 520 líneas de analítica, cadenas y RGPD.
- `admin/actions.ts` (1.674) — 21 endpoints HTTP en un fichero. `upsertCampaign` son 218 líneas que orquestan 4 tablas sin transacción, sincronizadas **a mano** con los ~50 `useState` del editor. Cambiar un campo obliga a tocar 3 sitios coordinados.
- `campaign-editor.tsx` (1.137) — ~50 estados independientes, sin aviso al salir: cerrar la pestaña descarta 15-20 minutos de trabajo.

Y `supabase/migrations/20260603120000_reconcile_prod_to_main.sql` (1.133 líneas) es **una copia de 15 migraciones anteriores concatenadas**, con `handle_new_auth_user` definida **tres veces** en el mismo fichero. Es evidencia de que producción divergió del repositorio.

**Código muerto verificado:** 4 tablas con cero lecturas, ~50 exports sin usar (incluidas 3 server actions —endpoints HTTP expuestos sin consumidor—), 5 rutas que solo redirigen, y **~80 `as never`** que existen para satisfacer una opción de configuración (`typedRoutes`) que **ni siquiera está activada**.

**Deuda declarada:** cero `TODO`/`FIXME` en todo el repo. Esto es engañoso: la deuda existe, pero escrita en prosa. Lo más relevante que el propio equipo reconoce por escrito: los términos legales están *"pendientes de revisión legal"* en producción, el rol de Ops es provisional, y el motor de contenido es un *"stub"*.

**Documentación desactualizada:** el `README.md` describe como *"pendientes"* fases que están terminadas, apunta a rutas de la máquina de otro desarrollador (`C:\Users\corre\...`) y remite a un `CLAUDE.md` que **está excluido del control de versiones** (`.gitignore:34-36`) pese a figurar como material de la sesión de traspaso.

**Coste de handover estimado:** 4-6 semanas para productividad razonable; 2-3 meses para autonomía en las zonas críticas. Para 27.500 líneas es caro — lo normal serían 2-3 semanas.

---

## 7. Lo que está bien (y conviene conservar)

Para que la crítica anterior sea creíble, hay que decir con la misma claridad qué está bien hecho:

- **El motor de cadenas QR es excelente.** `20260612000000_chains.sql`: las reglas de negocio BR-1 a BR-6 están como restricciones y triggers en la base de datos, la activación se serializa con `FOR UPDATE`, es idempotente ante reescaneo, y el `EXECUTE` está revocado de `anon`/`authenticated`. Está probado incluso con una carrera concurrente. Es el patrón que debería seguir el resto del sistema.
- **Los tests son reales**, no decorativos: 57 de integración contra base de datos real (aislamiento entre marcas con JWT reales, máquinas de estado, RGPD) y 18 e2e, incluido el flujo completo de cadena con dos usuarios. Incluyen un test de regresión de un bug real de producción.
- **RGPD tomado en serio**: consentimientos con IP, agente, versión y revocación fechada; purga automática por retención con tombstones y auditoría.
- **Los comentarios explican el porqué**, no el qué. Hay post-mortems de bugs embebidos en el código (`packages/types/src/status.ts:4-12`).
- **El aislamiento de credenciales entre clientes** (`.claude/` con hooks, deny-list y git-config por proyecto) es maduro y resuelve un problema operativo real.
- `packages/lib/src/kpi/aggregate.ts` y `evidence/validated.ts` están bien diseñados (reciben el cliente por parámetro, son testeables). Son el patrón a replicar.

---

## 8. Plan de remediación priorizado

### P0 — Antes de cualquier piloto real con datos de clientes (2-3 semanas)

1. `requireOps()` + comprobación de marca en las tres acciones de `(ops)/actions.ts`.
2. Restringir las transiciones a `APPROVED`/`REJECTED`/`FRAUD_BLOCKED` a personal validador (en la política o en el guard con `is_staff()`).
3. Derivar la marca del alta de una invitación válida en BD, nunca de metadatos del usuario.
4. Revertir el `GRANT ALL`, cerrar el INSERT de `audit_log` a `service_role`, quitar el respaldo a `user_metadata` en `current_brand_id()`.
5. Comprobar inscripción, pertenencia y ámbito en `submitEvidence`; comprobar propiedad en `kpis.csv`; no resucitar membresías `REVOKED`.
6. **Filtrar por marca y campaña en las consultas de KPI** — esto por sí solo corrige el reporting falso.
7. Añadir los 7 índices ausentes, empezando por `brand_membership(user_id)`.

### P1 — Antes de producción general (3-4 semanas)

8. Generar los tipos de Supabase (`supabase gen types`) y tipar los clientes: elimina los 50 `as unknown as` y la clase de bug más peligrosa. ~2 días de trabajo.
9. Quitar `ignoreBuildErrors`/`ignoreDuringBuilds`, añadir configuración de ESLint y ejecutar lint en CI. Hacer que el despliegue de migraciones **exija CI en verde**.
10. Comprobar errores y dar atomicidad a `submitEvidence` y a la creación de campañas; validar tamaño y tipo de archivo en servidor.
11. Unificar la validación de evidencias en un único módulo compartido por Ops y marca.
12. Aplicar `max_seats` y los límites de campos en servidor.
13. Activar PITR, respaldar Storage y hacer el primer ensayo de restauración.
14. Añadir `error.tsx`/`not-found.tsx` y caché de petición (`cache()` de React).

### P2 — Accesibilidad y producto (6-10 semanas)

15. Capa de primitivas accesibles compartida (identificadores, asociación etiqueta-campo, foco visible, regiones vivas). Corrige de una vez las tres barreras principales.
16. Diálogo de confirmación propio para las acciones destructivas.
17. Corregir el breakpoint inexistente y la duplicación de `AppShell`.
18. Decidir sobre i18n: instalar la infraestructura ahora (3-5 días) o aceptar formalmente que el requisito del RFP no se cumple.
19. **White-label**: columnas de branding en `brand`, variables CSS por tenant, logo dinámico.
20. **Capa de IA de validación**: el esquema ya la contempla (`ai_review_run`, `ValidationKind.AUTO`, `suspicious_threshold`, `FRAUD_BLOCKED`). Falta únicamente el motor.

---

## 9. Conclusión

El veredicto de *refactorizar, no reconstruir* se mantiene y se refuerza: el modelo de datos, el motor de cadenas y la suite de tests son activos reales que costaría meses reproducir.

Pero conviene ser preciso sobre qué se compró y qué se recibió. El proveedor documentó en `docs/respuestas-kynda-hito1.md` una reducción de alcance (solo fotos, validación manual, sin IA) y marca su Hito 1 como cumplido. **Lo entregado es coherente con esa acta, pero no con el RFP original**, que pedía explícitamente validación por IA (Módulo B) y generación de contenido con LLM (Módulo C) como pilares del producto.

Lo que sí queda fuera de cualquier interpretación de alcance es que **la plataforma tiene hoy seis vías verificadas de fuga o escalada entre marcas, y un reporting que empieza a dar cifras falsas al superar las mil evidencias**. Eso no es alcance reducido: es trabajo entregado con defectos, en un producto cuya propuesta de valor es exactamente la verificación fiable del impacto.

---

*Todos los hallazgos de severidad crítica y alta de este informe han sido verificados mediante lectura directa del código fuente.*
