# Tarea 67 — Deduplicación de Entidades, Solicitud de Asociación y Código Único — PLAN v2

**Proyecto:** LPDI SGR (eco.lpdi.co) · **Fecha:** 23 de julio de 2026 · **Estado:** PLAN v2 — incorpora tus 10 respuestas; pendiente de tu aprobación antes de ejecutar (tu punto 10)

---

## 0. Qué cambió respecto al v1 (resumen)

| # | Tu respuesta | Cómo quedó en el v2 |
|---|---|---|
| 1 | Correos enmascarados; rótulo "Admin en el sistema" | Tarjeta con `j***@acme.com` y rótulo "Admin en el sistema" (nunca "Admin" a secas). Nombre completo visible. §3.2 |
| 2 | "Crear nueva" siempre permitido, con auditoría; mismo usuario puede tener el mismo nombre en países distintos | Rediseñado el índice único: ahora incluye el **país** y se ancla al estado real de formalización. Además encontramos que el índice actual está **muerto** (no protege nada) — ver §1. §3.4 |
| 3 | Código solo al formalizar + regla transversal "nada en borrador recibe código" | Regla documentada para DESIGN.md. Verificado en eventos: el código formateado de eventos SÍ cumple (solo al enviar); el `codigo_unico` SERIAL de eventos NO cumple (se asigna al guardar el borrador) — señalado como deuda. §5.6 |
| 4 | Código: prefijo telefónico, año a confirmar, tipo granular extensible, consecutivo a 5 dígitos | Estructura reescrita: `TTT-PPP-AA-NNNNN` con taxonomía de tipos en tabla de catálogo (43 códigos seed, agregar uno = 1 fila). Ejemplos nuevos. §5 |
| 5 | Aprobador asigna permisos + cargo, gestionado en PS/PI | Confirmado: PS/PI es el lugar correcto y consistente con cómo se gestiona hoy el equipo (verificado en el código, ver §4.2). Flujo de aprobación ajustado. §4 |
| 6 | 10 días + recordatorio día 5 + correos a ambas partes en creación y resolución | Matriz de correos actualizada. §4.4 |
| 7 | Barrido de duplicados existentes | Incorporado como informe solo-lectura, y además es prerrequisito técnico del nuevo índice único. §6 |
| 8 | Código visible en PS/PI | Incorporado. §5.5 |
| 9 | Columna de código en las dos consultas | Incorporado con las reglas del DS de tablas anchas. §7 |
| 10 | Revisas el v2 antes de ejecutar | Este documento. Nada se ejecuta sin tu OK. |

**Hallazgo nuevo del v2 (importante):** al revisar el índice único para incorporar tu punto 2, encontramos que la protección actual contra duplicados del mismo usuario **dejó de operar** cuando se introdujo el modelo de formalización el 12 de julio. El detalle en §1. La buena noticia: el rediseño que pediste por países lo corrige de paso.

---

## 1. Diagnóstico actualizado (verificado en el código)

Lo del v1 sigue vigente: nada impide que **dos usuarios distintos** registren la misma empresa; el aviso del formulario es ignorable; el endpoint `/api/check-company-name` solo busca coincidencia exacta insensible a mayúsculas (pero sensible a estructura: no cruza startups contra ICGs ni usa el nombre legal).

**Hallazgo nuevo — el índice único actual está muerto:**

- El índice único vigente (migraciones `030` + `036`) es: `UNIQUE (registered_by_id, company_name) WHERE company_name != '' AND status != 'DRAFT'` (y su gemelo en ICGs con `organization_name`).
- Pero desde la migración `114` (formalización, decisión tuya del 12-jul), el estado real de una entidad es la columna `formalized_at` (NULL = borrador, con fecha = activa). La columna `status` quedó como legacy: las entidades se insertan con `status='DRAFT'` y **nada la cambia jamás** — el propio código de la consulta admin lo documenta: "`status` NO SIGNIFICA NADA — no existe proceso de aprobación" (`consulta/startups/+page.server.ts`).
- Consecuencia: como toda entidad queda con `status='DRAFT'` para siempre, la condición `status != 'DRAFT'` hace que el índice único **no aplique a ninguna fila**. Hoy un mismo usuario puede registrar dos veces "Acme" formalizada y la base no lo impide.
- En la Ola 0 verificaremos con `psql` el estado real en producción (regla nuestra: el estado real ≠ los archivos de migración), pero el código es concluyente.

Esto convierte el rediseño del constraint (tu punto 2) de un "ajuste" a una **reparación necesaria**, y explica por qué el barrido de duplicados (tu punto 7) pasa a ser también un prerrequisito técnico: antes de crear el índice nuevo hay que conocer y resolver las colisiones ya existentes, o la creación del índice falla.

**Otros datos verificados que condicionan el diseño:**

- `startups.country` / `icgs.country` guardan el **nombre del país en español** ("Colombia"), con algunos valores legacy en ISO2 ("CO") que el picker de países resuelve al vuelo. Para que el constraint por país y el código por país sean correctos, hay que normalizar esos valores legacy (data-fix acotado, con snapshot).
- Ya existen columnas normalizadas `company_name_norm` / `organization_name_norm` (lower + unaccent, migración `072`) con índice — se reutilizan para el matching y para el constraint.
- La extensión `pg_trgm` (similitud de texto) ya está habilitada (migración `049`) — el matching aproximado no necesita extensiones nuevas.

---

## 2. Evaluación (sin cambios de fondo)

La propuesta sigue siendo correcta y ejecutable: convertir el aviso pasivo en una decisión consciente (crear nueva vs. solicitar asociación), con verificación humana de pertenencia por parte de quien administra la entidad existente. Tus 10 respuestas cierran todas las decisiones abiertas del v1 salvo una (el año del código, §9.1), que dejamos como recomendación explícita para tu confirmación.

---

## 3. Diseño de deduplicación (actualizado)

### 3.1 Detección de coincidencias

Igual que el v1: nuevo endpoint que busca contra startups **e** ICGs en dos niveles — (1) coincidencia exacta normalizada (sin tildes ni mayúsculas, reutilizando `company_name_norm`/`organization_name_norm`); (2) coincidencia aproximada por similitud (`pg_trgm`) que captura "Acme SAS" vs "Acme S.A.S." y errores de tipeo. Compara nombre comercial, nombre legal y nombre de ICG. Solo entidades formalizadas y no eliminadas.

### 3.2 La tarjeta de coincidencia (con tus ajustes del punto 1)

```
Encontramos entidades parecidas a "Acme"
┌──────────────────────────────────────────────────┐
│ ACME S.A.S. (Startup) · Colombia                 │
│ Código: STA-057-26-00042                         │
│ Nombre legal: Acme Tecnología S.A.S.             │
│ Admin en el sistema: Juan Pérez (j***@acme.com)  │
│ CEO: María Gómez (m***@acme.com)                 │
│ Web: acme.com   ·   Coincidencia exacta          │
│ [Es mi empresa, solicitar asociación]            │
└──────────────────────────────────────────────────┘
[No es ninguna, crear entidad nueva]
```

- **Correos enmascarados** siempre: primera letra + `***` + dominio completo (`j***@acme.com`). El enmascarado se hace **en el servidor** — el correo completo nunca viaja al navegador.
- **Rótulo "Admin en el sistema"** (no "Admin"): deja claro que es quien registró la entidad en la plataforma, no un cargo dentro de la empresa. Nombre completo visible.
- Muestra el **país** y el **código único** (si la entidad ya lo tiene), para que los homónimos entre países se distingan de un vistazo.

### 3.3 "Crear nueva de todas formas"

Siempre permitido. Cuando el usuario ignora una **coincidencia exacta**, se deja rastro en `action_audit_log` con un `action_type` nuevo (p. ej. `entity_created_over_exact_match`), incluyendo en metadata el/los ids de las entidades que coincidían. Nota técnica obligatoria (gotcha conocido de este repo): agregar un `action_type` implica tocar **el tipo TS y el CHECK constraint** de `action_audit_log` (la última versión del CHECK está en la migración `128`; `logAction` traga el error si no se hace → falla silenciosa).

### 3.4 El índice único nuevo (tu punto 2 — mismo usuario, países distintos)

Reemplaza al índice muerto. Diseño:

```sql
-- startups (gemelo equivalente en icgs con organization_name)
DROP INDEX IF EXISTS startups_registered_by_company_unique;
CREATE UNIQUE INDEX startups_registered_by_company_country_unique
  ON startups (registered_by_id, immutable_unaccent(company_name), immutable_unaccent(country))
  WHERE company_name != ''
    AND formalized_at IS NOT NULL
    AND deleted_at IS NULL;
```

Qué logra, punto por punto:

| Caso | Resultado |
|---|---|
| Mismo usuario, mismo nombre, **mismo país** | **Bloqueado** (la protección real que pediste conservar) |
| Mismo usuario, mismo nombre, **países distintos** | **Permitido** (tu regla nueva: "Acme Colombia" y "Acme Perú" del mismo usuario conviven) |
| Mismo usuario, N borradores con el mismo nombre | Permitido (los borradores no cuentan — `formalized_at IS NULL`) |
| Usuarios distintos, mismo nombre | Permitido a nivel de base (como hoy) — lo gobierna el flujo de coincidencias + asociación, no un bloqueo duro |
| "Ácme" vs "acme" | Cuentan como el mismo nombre (normalización con `immutable_unaccent`, ya existente) |

Prerrequisitos en orden (por eso el barrido va primero):
1. **Data-fix de países legacy**: convertir los valores ISO2 ("CO") a nombre ("Colombia") para que el país del constraint compare manzanas con manzanas. Acotado, con snapshot previo (Supabase sin PITR) y reversible.
2. **Pre-chequeo de colisiones**: query que detecta filas que violarían el índice nuevo. Si hay colisiones, van al informe del barrido (§6) y las resuelve el equipo LPDI caso por caso ANTES de crear el índice (si no, el `CREATE UNIQUE INDEX` falla).
3. Crear el índice nuevo y eliminar el viejo. Todo idempotente y reversible.

---

## 4. Flujo de solicitud de asociación (actualizado)

### 4.1 Lo que se mantiene del v1

Nueva tabla `entity_join_requests` con token. **Quién aprueba** = exactamente quien ya puede editar la entidad: en startup, el registrador, el CEO o un miembro verificado con edición; en ICG, **solo el registrador** (regla dura tuya: los contactos ICG nunca editan). El solicitante debe estar verificado (Opción B). Vigencia 10 días, recordatorio al día 5 — replicando el motor temporal ya probado de las transferencias (RPC tick + cron + correos, patrón de `entity_transfer_tick`).

### 4.2 Al aprobar: permisos + cargo, en el PS/PI (tu punto 5)

**Confirmado: sí, el panel de la entidad es el lugar correcto**, y es consistente con cómo se gestiona hoy el equipo. Verificado en el código:

- **Startups**: los miembros viven en `startup_founders`, que ya tiene las columnas exactas que necesitamos — `role` (cargo), `can_edit_form` (permiso de edición) e `is_ceo` — y hoy se gestionan en el **Panel 5C "Equipo"** del formulario de startup, al que se llega desde el Panel de Startup (`/dashboard/perfil-startup`). La vista personal "Gestión de permisos" del PU muestra estos mismos roles (Admin / CEO / Miembro / Miembro + Edición).
- **ICGs**: los contactos viven en `icg_contacts` (con campo `position` = cargo y `verification_status`), gestionados desde el formulario ICG al que se llega por el Panel de ICG (`/dashboard/perfil-icg`).

**Flujo ajustado:** las solicitudes pendientes aparecen en el PS (startup) o PI (ICG) de la entidad. Al aprobar, en esa misma pantalla el aprobador asigna de una vez:

- **Startup**: el **cargo** (`role`) y los **permisos** (`can_edit_form` sí/no; default: sin edición). El aprobado entra como miembro **verificado** (ambas partes consintieron: él solicitó, el aprobador aceptó).
- **ICG**: el **cargo** (`position`). No hay permiso de edición que asignar — los contactos ICG nunca editan (tu regla dura se mantiene intacta); el aprobado entra como contacto verificado.

### 4.3 Ciclo de vida

`pending` → (`approved` | `rejected` | `expired` a los 10 días). Recordatorio al aprobador el día 5. Todo transiciona vía RPC atómica (sin dobles aprobaciones) y deja rastro en `action_audit_log` (action_types nuevos: `join_request_created`, `join_request_approved`, `join_request_rejected`, `join_request_expired` — mismo gotcha del CHECK, §3.3).

### 4.4 Matriz de correos (actualizada con tu punto 6)

| Evento | Al solicitante | Al aprobador / aprobadores |
|---|---|---|
| Solicitud creada | ✅ constancia "tu solicitud fue enviada a los administradores de X" | ✅ con la tarjeta del solicitante y el enlace de decisión |
| Día 5 sin respuesta | — | ✅ recordatorio |
| Aprobada | ✅ "ya haces parte de X" (con su cargo) | ✅ confirmación de lo que aprobó |
| Rechazada | ✅ notificación (sin exponer quién rechazó) | ✅ constancia |
| Expirada (día 10) | ✅ "venció sin respuesta; puedes crear la entidad o volver a intentar" | ✅ constancia informativa |

(La fila "expirada → ambas partes" es una extensión menor de tu regla, por simetría; puedes recortarla — §9.4.)

Los renders de correo siguen el patrón del repo (script de render separado, mismo layout de los correos de entidad existentes).

---

## 5. Código único de entidad (reescrito con tus 4 ajustes del punto 4)

### 5.1 Estructura

**`TTT-PPP-AA-NNNNN`** — ejemplos: `STA-057-26-00042`, `VCF-051-26-00007`.

| Segmento | Qué es | Detalle |
|---|---|---|
| `TTT` | **Tipo granular** (3 letras) | Subcategoría de segundo nivel, desde tabla de catálogo (§5.2). Reemplaza al ST/IC plano del v1 |
| `PPP` | **Prefijo telefónico del país** (3 dígitos) | El MISMO criterio del código de eventos, verificado en el código: se replica `getPhonePrefix3` — Colombia → `057`, Perú → `051`, México → `052`; sin país → `000`. (Eventos usa el prefijo telefónico rellenado a 3 dígitos, no el ISO numérico) |
| `AA` | **Año** (2 dígitos) | Recomendación: año de **registro en el sistema** (fecha de formalización) — pendiente de tu confirmación, §9.1 |
| `NNNNN` | **Consecutivo a 5 dígitos** | Contador atómico en base de datos por año, réplica exacta del patrón de eventos (`evento_codigo_counters` + RPC `next_evento_consecutivo`): tabla `entidad_codigo_counters` + RPC propia. Sin colisiones por concurrencia |

El consecutivo se propone **por año, compartido entre startups e ICGs** (igual que eventos, que llevan un solo contador por año): mantiene los números cortos y el código ya distingue el tipo en `TTT`. Si prefieres contadores separados por tipo, es un cambio de una línea — §9.2.

### 5.2 Taxonomía de códigos de tipo (extensible — tu punto 4c)

**Nueva tabla de catálogo `catalogo_codigo_tipo_entidad`**: mapea cada subcategoría de segundo nivel a su código corto de 3 letras. Agregar una categoría o subcategoría nueva = **insertar 1 fila** (no tocar código de aplicación).

```sql
CREATE TABLE catalogo_codigo_tipo_entidad (
  id           SERIAL PRIMARY KEY,
  rol_codigo   VARCHAR(100) NOT NULL UNIQUE,  -- referencia al codigo de catalogo_rol_ecosistema
  codigo_corto CHAR(3)      NOT NULL UNIQUE,  -- el TTT del código de entidad
  activo       BOOLEAN      NOT NULL DEFAULT true,
  created_at   TIMESTAMPTZ  NOT NULL DEFAULT NOW()
);
```

**Cómo conecta con lo que ya existe** (verificado en el código):

- El catálogo `catalogo_rol_ecosistema` (43 filas: `codigo`, `label`, `categoria`) **ya contiene el segundo nivel completo**, y ya cubre a las startups: tiene las filas `Startup`, `Scaleup` y `PyME` (categoría "Startup"). Es la columna vertebral perfecta para colgar el código corto.
- **Startups**: `company_type` (enum `STARTUP`/`PYME`/`SCALEUP`) → fila del catálogo (`Startup`/`PyME`/`Scaleup`) → `codigo_corto`.
- **ICGs**: la columna `icg_role` guarda exactamente el `codigo` del catálogo (p. ej. `Venture Capital Fund - VC`) → `codigo_corto` directo. Fallback para ICGs legacy sin `icg_role`: código `OTR`.

**Seed propuesto (los 43 códigos — puedes ajustar cualquier letra, §9.3):**

| Categoría | Subcategoría (2.º nivel) | Código |
|---|---|---|
| Inversionista Individual | Ángel Inversionista | `ANG` |
| Inversionista Individual | Limited Partner | `LIP` |
| Inversionista Individual | General Partner | `GEP` |
| Inversionista Individual | Lead Investor | `LIN` |
| Inversionista Institucional | Family Office | `FAM` |
| Inversionista Institucional | Fondo de Inversión | `FDI` |
| Inversionista Institucional | Banca de Inversión | `BAN` |
| Inversionista Institucional | Venture Capital Fund – VC | `VCF` |
| Inversionista Institucional | Corporate Venture Capital – CVC | `CVC` |
| Inversionista Institucional | Government Venture Capital – GVC | `GVC` |
| Gestor especializado | Incubadora | `INC` |
| Gestor especializado | Aceleradora | `ACE` |
| Gestor especializado | Cámara de Comercio | `CAM` |
| Gestor especializado | Caja de Compensación | `CDC` |
| Gestor especializado | Company Builder | `CBU` |
| Gestor especializado | Startup Studio | `SST` |
| Corporativo | Empresa Privada | `EMP` |
| Corporativo | Empresa Mixta | `EMX` |
| Consultora | Gran consultora | `GCO` |
| Consultora | Consultora boutique | `CBO` |
| Consultora | Consultor independiente | `COI` |
| Startup | Startup | `STA` |
| Startup | Scaleup | `SCA` |
| Startup | PyME | `PYM` |
| Gobierno | Entidad Pública Municipal | `GPM` |
| Gobierno | Entidad Pública Regional | `GPR` |
| Gobierno | Entidad Pública Nacional | `GPN` |
| Gobierno | Organismo Multilateral | `OMU` |
| Academia e Innovación | Universidad | `UNI` |
| Academia e Innovación | Centro de Innovación | `CEI` |
| Academia e Innovación | Centro de Investigación | `CEV` |
| Academia e Innovación | Otros centros de formación | `OCF` |
| Hub y Redes | Hub de Innovación | `HUB` |
| Hub y Redes | Red de Innovación | `RDI` |
| Hub y Redes | Red de Emprendimiento | `RDE` |
| Hub y Redes | Clúster Tecnológico | `CLU` |
| Hub y Redes | Comunidad | `COM` |
| Otros | Asociación | `ASO` |
| Otros | Gremio | `GRE` |
| Otros | Fundación | `FUN` |
| Otros | Medio de comunicación | `MED` |
| Otros | ONG | `ONG` |
| Otros | Otro | `OTR` |

### 5.3 Ejemplos completos

| Entidad | Código | Lectura |
|---|---|---|
| Startup en Colombia, formalizada en 2026, consecutivo 41 | `STA-057-26-00041` | Startup · Colombia (+57) · 2026 · #41 |
| PyME en México, 2026, consecutivo 87 | `PYM-052-26-00087` | PyME · México (+52) · 2026 · #87 |
| ICG fondo VC en Perú, 2026, consecutivo 12 | `VCF-051-26-00012` | Venture Capital Fund · Perú (+51) · 2026 · #12 |
| ICG Cámara de Comercio en Colombia, 2026, consecutivo 104 | `CAM-057-26-00104` | Cámara de Comercio · Colombia (+57) · 2026 · #104 |

Nota: el código es **inmutable** desde su asignación. Si la entidad luego cambia de tipo o de país, el código no se regenera (es un identificador, no un atributo vivo) — igual criterio que eventos.

### 5.4 Cuándo se asigna, backfill

- **Solo al formalizar** (tu punto 3): la asignación se engancha en `formalizeEntity()` — el único punto del código por donde pasa toda formalización (público y dashboard), ya idempotente a nivel de base. Los borradores abandonados no queman números.
- **Backfill** a todas las entidades ya formalizadas, ordenadas por `formalized_at` ascendente (las más antiguas reciben los números más bajos), con el año de su propia formalización en `AA`.

### 5.5 Dónde se ve el código

- **PS y PI** (paneles de startup e ICG) — tu punto 8: visible de forma prominente, para que los usuarios lo conozcan y lo usen.
- Ficha de la entidad, tarjeta de coincidencias (§3.2), correos de la entidad, y columna en las dos consultas admin (§7).

### 5.6 Regla transversal nueva del sistema (tu punto 3) — para DESIGN.md

> **Nada que esté en BORRADOR recibe código.** Los códigos únicos del sistema (entidades, eventos, convocatorias, noticias y cualquier módulo futuro) se asignan únicamente al formalizar/enviar, nunca al crear o autoguardar un borrador. Un borrador abandonado no debe quemar números ni dejar identificadores huérfanos.

**Verificación de cómo se comportan hoy los eventos** (lo pediste explícito):

- El **código formateado** de eventos (`evento_codigo`, formato `M-PPP-TTT-AA-NNNNN`) **cumple la regla**: se genera únicamente en el submit final; el autosave de borradores lo deja en NULL (está documentado en el propio código: "evento_codigo NO se genera en drafts").
- **PERO** los eventos tienen un segundo identificador: `codigo_unico`, columna `SERIAL` que la base asigna **al insertar la fila — incluidos los borradores del autosave**. Ese número se usa en los enlaces públicos de compartir (`/eventos/{codigo}`). **No cumple la regla nueva**: un borrador abandonado quema un número. Lo señalamos como **deuda a corregir** para cumplir la regla (no la corregimos dentro de esta tarea salvo que lo pidas — §9.5); quedará listada en DESIGN.md junto a la regla.

El código de entidades nace ya conforme a la regla (solo en `formalizeEntity`).

---

## 6. Barrido de duplicados existentes (tu punto 7)

Informe **solo lectura** sobre la base actual (sin tocar ni un dato), con el mismo matching del §3.1:

- Pares/grupos de posibles duplicados: exacto normalizado y aproximado por similitud, cruzando startups ↔ ICGs y nombre comercial/legal/organización.
- Por cada grupo: nombres, tipo, país, admin en el sistema (correo enmascarado en el informe), fecha, completitud — lo necesario para que el equipo LPDI decida caso por caso.
- **Sección especial "colisiones duras"**: los grupos que violarían el índice único nuevo (mismo usuario + mismo nombre + mismo país, formalizadas). Estos son bloqueantes: hay que resolverlos antes de crear el índice (§3.4).
- Entregable: informe legible (markdown/HTML) para ti y el equipo. Este barrido corre en la Ola 0, antes de cualquier migración.

---

## 7. Columna "Código" en las consultas (tu punto 9)

Verificado el estado actual de ambas páginas:

- `/dashboard/consulta/startups`: columnas Compañía · Tipo · Estado · Transferida · Ubicación · TRL · CRL · Base tec. · Completitud · Sitio web · **Acciones** (sticky).
- `/dashboard/consulta/icg`: Organización · Tipo · Estado · Transferida · Perfil · Ubicación · Convocatoria · Completitud · Sitio web · **Acciones** (sticky).

Se agrega **Código** inmediatamente después del nombre (Compañía/Organización), ordenable, con `—` para entidades sin código (borradores). Cumpliendo las reglas del DS de tablas anchas (DESIGN.md): mismos valores fijos de toda tabla ancha del dashboard, columna **Acciones sticky a la derecha intacta** (con su sombra de separación y su `skipClass` en la medición de anchos), scroll horizontal en el contenedor — prohibido ajustar una tabla por su cuenta.

---

## 8. Plan de implementación (13 tareas, 5 olas)

Todas las migraciones: **aditivas, idempotentes y reversibles**, con snapshot previo (Supabase sin PITR, prohibido DELETE sin snapshot). Numeración de migraciones desde `130_`. Zonas de alto riesgo (migraciones + data-fix + RLS) con **manos Opus** y verificación con evidencia (psql real, no reporte en prosa); las tareas mecánicas con Sonnet.

**Waves:**

| Wave | Tasks | Depende de | Parallelizable | Riesgo |
|------|-------|-----------|----------------|--------|
| 0 | 1, 2 | — | Sí (solo lectura + doc) | Bajo |
| 1 | 3, 4, 5 | Wave 0 (T3 requiere colisiones resueltas del T1) | Sí | **ALTO — Opus** (migraciones) |
| 2 | 6, 7, 8 | Wave 1 | Sí | Medio — Opus |
| 3 | 9, 10, 11, 12 | Wave 2 | Sí | Bajo-medio |
| 4 | 13 | Waves 1–3 | No (integración) | Medio — Opus |

### Task 1: Barrido de duplicados + verificación del estado real _(Wave 0)_ — manos Opus

**Files:**
- Create: script de informe solo-lectura (queries de matching exacto/aproximado + colisiones duras)
- Create: informe entregable para el equipo LPDI

**Done when:**
- [ ] Informe generado con grupos de duplicados (exactos y aproximados) y sección "colisiones duras" del índice nuevo
- [ ] Verificado con psql el estado real: `pg_indexes` de los índices únicos actuales + conteo de filas con `status != 'DRAFT'` (confirmar el hallazgo del índice muerto)
- [ ] Cero escrituras a la base: el script solo hace SELECT (revisión del código del script)

### Task 2: Regla transversal en DESIGN.md + deuda de eventos _(Wave 0)_ — manos Sonnet

**Files:**
- Edit: `DESIGN.md` (regla "nada en borrador recibe código" + deuda `codigo_unico` SERIAL de eventos)

**Done when:**
- [ ] DESIGN.md contiene la regla transversal con la redacción del §5.6 y la deuda de eventos documentada
- [ ] No se tocó ningún archivo de código (solo documentación)

### Task 3: Migración — índice único nuevo + data-fix países legacy _(Wave 1)_ — **ALTO RIESGO, manos Opus**

**Files:**
- Create: `supabase/migrations/130_dedup_unique_index_country.sql` (+ rollback)

**Done when:**
- [ ] Snapshot previo tomado y verificado (conteo de filas coincide)
- [ ] Data-fix ISO2→nombre aplicado solo a los valores legacy detectados; `SELECT` de control devuelve 0 países en formato ISO2
- [ ] Índice nuevo creado y viejo eliminado; probado con psql: inserción duplicada (mismo usuario+nombre+país formalizada) falla con 23505, y mismo nombre en país distinto pasa
- [ ] Idempotencia probada: la migración corre dos veces sin error
- [ ] Rollback probado en local: restaura índice viejo y no pierde datos

### Task 4: Migración — tabla `entity_join_requests` + RPC tick _(Wave 1)_ — **ALTO RIESGO, manos Opus**

**Files:**
- Create: `supabase/migrations/131_entity_join_requests.sql` (+ rollback)

**Done when:**
- [ ] Tabla con estados, token, expiración a 10 días y RLS deny-by-default (acceso vía service role, patrón `icg_contacts`)
- [ ] RPC de transición atómica probada con psql: dos aprobaciones concurrentes → solo una gana
- [ ] RPC tick (expiración + recordatorio día 5) probada con filas de prueba retro-fechadas y limpiadas después
- [ ] Idempotencia y rollback probados

### Task 5: Migración — catálogo de códigos + contador + columnas de código + backfill + audit CHECK _(Wave 1)_ — **ALTO RIESGO, manos Opus**

**Files:**
- Create: `supabase/migrations/132_entity_code.sql` (+ rollback)

**Done when:**
- [ ] `catalogo_codigo_tipo_entidad` creada y sembrada con las 43 filas del §5.2; `codigo_corto` UNIQUE
- [ ] Columna `entity_code` (UNIQUE, nullable) en startups e icgs + tabla `entidad_codigo_counters` + RPC consecutivo atómica (réplica del patrón de eventos)
- [ ] Backfill completo: 100% de entidades formalizadas con código, orden por `formalized_at` asc verificado con psql (muestra de 10 antiguas/nuevas); borradores en NULL
- [ ] CHECK de `action_audit_log` reescrito con los 5 action_types nuevos; INSERT de prueba de cada tipo pasa (y se limpia)
- [ ] Idempotencia y rollback probados

### Task 6: Backend — endpoint de coincidencias _(Wave 2)_ — manos Opus

**Files:**
- Create: `src/routes/api/entidades/similares/+server.ts` (nombre final según convención del repo)
- Create: tests del enmascarado y del matching

**Done when:**
- [ ] Responde exactos + aproximados cruzando startups/ICGs con los campos de la tarjeta §3.2
- [ ] Correos enmascarados EN EL SERVIDOR (test: la respuesta JSON nunca contiene un correo completo) y rótulo "Admin en el sistema"
- [ ] Solo entidades formalizadas y no eliminadas; rate-limit/min-length para no ser un oráculo de scraping
- [ ] `npm run test` y `npm run lint` sin errores nuevos

### Task 7: Backend — lib de solicitudes + correos + cron _(Wave 2)_ — manos Opus

**Files:**
- Create: `src/lib/server/join-requests.ts` + renders de correo (script de render separado, patrón del repo)
- Create: `src/routes/api/cron/join-request-tick/+server.ts` (patrón `entity-transfer-tick`, protegido por CRON_SECRET)
- Edit: vercel.json (cron nuevo — recordar: los crons de Vercel disparan por GET)

**Done when:**
- [ ] Los 5 correos de la matriz §4.4 se envían a las partes correctas (test por evento: creada→2, aprobada→2, rechazada→2, expirada→2, recordatorio→1)
- [ ] Aprobación crea la fila en `startup_founders` (con `role` + `can_edit_form` elegidos) o `icg_contacts` (con `position`), verificada
- [ ] Solicitante no verificado (Opción B) recibe 403; auditoría registrada en cada transición
- [ ] Tests pasan; render de correos generado y revisado visualmente

### Task 8: Backend — asignación de código al formalizar _(Wave 2)_ — manos Opus

**Files:**
- Edit: `src/lib/server/entities.ts` (formalizeEntity) + helper de composición del código
- Create: tests del compositor (TTT/PPP/AA/NNNNN, fallbacks OTR/000)

**Done when:**
- [ ] Toda formalización (público y dashboard) asigna código exactamente una vez; idempotente (segunda llamada no regenera)
- [ ] Fallbacks probados: ICG sin `icg_role` → OTR; sin país → 000; país legacy ISO2 → resuelve el prefijo correcto
- [ ] `PPP` replica el criterio de eventos (CO→057, PE→051) con test que lo compara contra `getPhonePrefix3`
- [ ] Tests y lint sin errores nuevos

### Task 9: UI — modal de coincidencias en los 4 formularios _(Wave 3)_ — manos Opus

**Files:**
- Create: componente de tarjetas de coincidencia (consumiendo el DS)
- Edit: FSS/FSD/FIS/FID (los 4 shells de formulario)

**Done when:**
- [ ] Al salir del campo nombre con coincidencias, aparece el panel §3.2 en los 4 formularios (capturas Playwright de cada uno)
- [ ] "Crear de todas formas" sobre coincidencia exacta deja el rastro de auditoría (verificado en `action_audit_log` con psql)
- [ ] "Solicitar asociación" crea la solicitud y muestra la confirmación
- [ ] UI consume el DS (sin estilos nuevos fuera de tema); build sin errores

### Task 10: UI — solicitudes y aprobación en PS/PI con cargo + permisos _(Wave 3)_ — manos Opus

**Files:**
- Edit: `/dashboard/perfil-startup` y `/dashboard/perfil-icg` (sección de solicitudes pendientes + pantalla de decisión)

**Done when:**
- [ ] El aprobador ve la tarjeta del solicitante y al aprobar asigna cargo + permisos (startup) o cargo (ICG) en la misma pantalla (capturas Playwright)
- [ ] En ICG NO se ofrece permiso de edición (regla dura verificada en la UI y en el server)
- [ ] Rechazo pide confirmación y notifica; estados vacíos correctos
- [ ] Build y lint sin errores

### Task 11: UI — código visible en PS/PI y ficha _(Wave 3)_ — manos Sonnet

**Files:**
- Edit: PS, PI y fichas de entidad (mostrar `entity_code` con acción copiar)

**Done when:**
- [ ] Código visible en PS, PI y ficha; borradores muestran el estado "se asignará al formalizar" (capturas)
- [ ] Sin regresiones visuales en los paneles (comparación antes/después)

### Task 12: UI — columna Código en las dos consultas _(Wave 3)_ — manos Sonnet

**Files:**
- Edit: `/dashboard/consulta/startups/+page.svelte` (+ server) y `/dashboard/consulta/icg/+page.svelte` (+ server)

**Done when:**
- [ ] Columna "Código" tras el nombre, ordenable, `—` en vacíos, en ambas consultas (capturas Playwright)
- [ ] Reglas del DS intactas: Acciones sticky derecha con sombra y medición con `skipClass` sin romper (scroll horizontal verificado en viewport angosto)
- [ ] Build y lint sin errores

### Task 13: Integración — flujo REAL completo + deploy _(Wave 4)_ — manos Opus, verificación Fable

**Done when:**
- [ ] Flujo real completo en preview, sin caveats pendientes: registro con nombre duplicado → tarjeta → solicitar asociación → correos a ambas partes → aprobar con cargo+permisos → miembro verificado visible; y el camino "crear de todas formas" → auditoría + código asignado al formalizar
- [ ] Recordatorio día 5 y expiración día 10 probados con filas retro-fechadas (y limpiadas)
- [ ] Smoke test de las páginas tocadas + deploy verificado **por commit SHA** (no solo READY)
- [ ] Códigos en producción: muestra aleatoria de 10 entidades cumple el formato y la unicidad (psql)

---

## 9. Recomendaciones que Frank debe confirmar

1. **(Tu 4b) El `AA` del código = año de REGISTRO EN EL SISTEMA** (fecha de formalización), no año de creación de la empresa. Justificación: (a) siempre se conoce y nunca cambia — el año de fundación puede ser desconocido, disputado o muy antiguo ("1987" en un identificador de sistema no aporta); (b) hace el código verificable contra la base (coincide con `formalized_at`); (c) es el mismo criterio del código de eventos (año del evento en el sistema); (d) el dato de fundación ni siquiera es un campo confiable/obligatorio hoy. **Recomendación: año de registro. ¿Confirmas?**
2. **Consecutivo `NNNNN` por año, compartido entre startups e ICGs** (un solo contador anual, como eventos). Alternativa: contador separado por tipo. Recomendamos compartido: números más informativos ("cuántas entidades entraron este año") y el tipo ya está en `TTT`. **¿Confirmas?**
3. **Los 43 códigos cortos propuestos** (§5.2): revisa la tabla por si quieres ajustar alguna sigla (es solo cambiar la fila del seed antes de ejecutar).
4. **Correo de expiración a ambas partes** (§4.4, última fila): lo agregamos por simetría con tu regla de "ambas partes"; si lo prefieres solo al solicitante, se recorta.
5. **Deuda del `codigo_unico` SERIAL de eventos** (§5.6): viola la regla nueva (se asigna al guardar borrador). Recomendamos registrarla como deuda y corregirla en una tarea aparte (tocarla implica revisar los enlaces públicos de compartir eventos); no la incluimos en el alcance de la 67. **¿De acuerdo?**
6. **Data-fix de países legacy ISO2 → nombre** (§3.4): es prerrequisito del constraint por país y del prefijo telefónico correcto. Acotado, con snapshot y reversible. Se ejecuta dentro de la Task 3. **¿De acuerdo?**

---

**Resumen:** el v2 incorpora tus 10 respuestas y además repara un hueco que encontramos al revisarlas: el índice único actual quedó inoperante desde el modelo de formalización de julio, así que el rediseño por países que pediste es también la reparación de la protección base. El código único queda `TTT-PPP-AA-NNNNN` con prefijo telefónico (criterio de eventos), tipo granular extensible por catálogo (agregar subcategoría = 1 fila) y consecutivo a 5 dígitos, asignado solo al formalizar bajo la regla transversal nueva "nada en borrador recibe código". La aprobación de miembros vive en el PS/PI con cargo + permisos en una sola pantalla, con correos a ambas partes en cada hito. 13 tareas en 5 olas, migraciones aditivas y reversibles con snapshot, alto riesgo con manos Opus y verificación con evidencia. Con tu OK (y tu confirmación de los 6 puntos del §9) arrancamos por la Ola 0.
