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

**Proyecto:** LPDI SGR (eco.lpdi.co) · **Fecha:** 23 de julio de 2026 · **Estado:** PLAN v3 — incorpora tus 6 decisiones sobre el v2; para tu aprobación FINAL antes de ejecutar

---

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

| # | Tu decisión sobre el v2 | Cómo quedó en el v3 |
|---|---|---|
| 1 | **Año del código (4b): CONFIRMADO = año de registro en el sistema** (fecha de formalización) | Cerrado. Ya no aparece en las recomendaciones a confirmar. §5.1 |
| 2 | **Consecutivo NNNNN: SEPARADO por tipo** (startups e ICG llevan secuencias independientes), a 5 dígitos | Rediseñado el contador: tabla única con clave `(tipo, año)` — dos secuencias por año, una S y una I — RPC atómica, mismo patrón de eventos. §5.1 |
| 3 | **Prefijo de categoría S/I al inicio del código** | Formato nuevo: **`S-TTT-PPP-AA-NNNNN`** (startups) / **`I-TTT-PPP-AA-NNNNN`** (ICG). Reescritos §5.1, §5.3 y todos los ejemplos del documento. Nota 5 vs 6 dígitos en §9.1 |
| 4 | **Correo de expiración a ambas partes: aprobado tal cual** | Cerrado. La matriz de correos §4.4 queda como estaba en el v2; sale de las recomendaciones a confirmar |
| 5 | **`codigo_unico` SERIAL de eventos: deja de ser deuda, ENTRA AL PLAN como tarea** ("hay que resolverlo; si quiere lo deja al final, pero debe hacer parte del plan") | Nueva **Task 14 (Ola 5, la última)**: asignar el `codigo_unico` solo al enviar el evento, nunca en el borrador del autosave, sin romper los enlaces públicos `/eventos/{codigo}` existentes. Diseño verificado en el código, §5.6 y Task 14. Sale de la §9 |
| 6 | **Países: NO hay data-fix ISO2 — eliminado del plan** | Verificado y corregido: el supuesto "valores legacy ISO2" del v2 era equivocado. `startups.country`/`icgs.country` guardan el nombre oficial en español; el SSOT es `COUNTRIES` en `src/lib/data/countries.ts` y el prefijo sale de `getPhonePrefix3` de ese mismo listado. El índice único por país **se mantiene** (sigue siendo necesario), pero sin paso de data-fix. Reescritos §1, §3.4 y Task 3. Un matiz verificado con evidencia en §1 (la fila "Peru" sin tilde) que se resuelve en el helper, no con data-fix |

Todo lo demás del v2 se mantiene intacto: el hallazgo del índice único muerto (§1), la tarjeta con correos enmascarados y rótulo "Admin en el sistema" (§3.2), el flujo de asociación con permisos + cargo gestionado en PS/PI y correos a ambas partes (§4), la taxonomía de 43 códigos (§5.2, ahora con el prefijo S/I delante), el barrido de duplicados (§6), la columna de código en las dos consultas (§7), el código visible en PS/PI (§5.5) y la regla transversal "nada en borrador recibe código" (§5.6).

---

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

Lo esencial 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 (no cruza startups contra ICGs ni usa el nombre legal).

**Hallazgo del v2 que se mantiene — 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`).
- Desde la migración `114` (formalización, 12-jul), el estado real de una entidad es `formalized_at` (NULL = borrador, con fecha = activa). La columna `status` quedó 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").
- Consecuencia: la condición `status != 'DRAFT'` hace que el índice **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 se verifica 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 en una **reparación necesaria**, y el barrido de duplicados en prerrequisito técnico: hay que resolver las colisiones existentes antes de crear el índice nuevo, o el `CREATE UNIQUE INDEX` falla.

**Países — hallazgos verificados (corrige el supuesto del v2):**

- El **SSOT de países** es la constante `COUNTRIES` en `src/lib/data/countries.ts` (`nombre_es`, `iso_alpha2`, `iso_alpha3`, `region_lpdi`, `codigo_telefono`). Es el listado oficial armonizado que usa TODO el sistema. **No existe ni se creará ningún listado paralelo.**
- El **prefijo telefónico** del código sale de ese listado: `getPhonePrefix3(paisOrNombre)` lee `codigo_telefono` y acepta tanto el ISO2 como el `nombre_es` ("Colombia" → `057`, "CO" → `057`), rellenado/truncado a 3 dígitos; sin país o país no reconocido → `000`.
- En la **data real**, `startups.country` / `icgs.country` guardan el **nombre oficial** ("Colombia", "Perú", "México"…). No hay valores ISO2 (verificado: cero filas con `country` de 3 caracteres o menos). El "data-fix ISO2→nombre" del v2 fue un supuesto equivocado y **queda eliminado del plan**.
- Anomalías menores reales: un puñado de filas con `country` vacío (→ prefijo `000`, comportamiento correcto para entidad sin país declarado) y una fila **"Peru" sin tilde**. **Matiz verificado con evidencia** (ejecutado contra el código real): el match por nombre de `getPhonePrefix3` es insensible a mayúsculas pero **NO a tildes** — hoy `getPhonePrefix3('Peru')` devuelve `000` y `getPhonePrefix3('Perú')` devuelve `051`. No amerita data-fix: el **helper compositor del código de entidad** (Task 8) normaliza tildes antes del lookup (envoltorio accent-insensitive sobre `getPhonePrefix3`), lo que resuelve esa fila y blinda cualquier futura. Para el **índice único**, `immutable_unaccent(country)` ya trata "Peru" y "Perú" como el mismo país.
- Ya existen columnas normalizadas `company_name_norm` / `organization_name_norm` (lower + unaccent, migración `072`) con índice — se reutilizan para el matching y el constraint. La extensión `pg_trgm` ya está habilitada (migración `049`) — el matching aproximado no necesita extensiones nuevas.

---

## 2. Evaluación (sin cambios)

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 quien administra la entidad existente. Con tus 6 decisiones sobre el v2, quedan cerradas todas las decisiones de diseño; solo restan las dos confirmaciones menores de la §9.

---

## 3. Diseño de deduplicación

### 3.1 Detección de coincidencias

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

```
Encontramos entidades parecidas a "Acme"
┌──────────────────────────────────────────────────┐
│ ACME S.A.S. (Startup) · Colombia                 │
│ Código: S-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 (`entity_created_over_exact_match`), incluyendo en metadata 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` (última versión del CHECK en la migración `128`; `logAction` traga el error si no se hace → falla silenciosa).

### 3.4 El índice único nuevo (mismo usuario, países distintos) — SIN data-fix

Reemplaza al índice muerto. El país ya está almacenado con su **nombre oficial** en toda la data real (verificado, §1), así que el índice solo necesita la normalización unaccent que ya se usa en el resto del sistema — **no hay paso previo de corrección de datos**. 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 a conservar) |
| Mismo usuario, mismo nombre, **países distintos** | **Permitido** (tu regla: "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" · "Peru" vs "Perú" | Cuentan como iguales (normalización con `immutable_unaccent`, ya existente) |

Prerrequisitos en orden (por eso el barrido va primero):
1. **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).
2. Crear el índice nuevo y eliminar el viejo. Todo idempotente y reversible, con snapshot previo (Supabase sin PITR).

---

## 4. Flujo de solicitud de asociación

### 4.1 Base

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

Verificado en el código que el panel de la entidad es el lugar correcto y consistente con cómo se gestiona hoy el equipo:

- **Startups**: los miembros viven en `startup_founders`, que ya tiene las columnas exactas necesarias — `role` (cargo), `can_edit_form` (permiso de edición) e `is_ceo` — gestionadas hoy 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:** 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 (APROBADA tal cual por ti — punto 4)

| 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 de expiración a ambas partes quedó **aprobada** — ya no es una recomendación pendiente. 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

### 5.1 Estructura (con tus puntos 2 y 3 del v3)

**`S-TTT-PPP-AA-NNNNN`** para startups · **`I-TTT-PPP-AA-NNNNN`** para ICG.

Ejemplos: `S-STA-057-26-00042`, `I-VCF-051-26-00012`.

| Segmento | Qué es | Detalle |
|---|---|---|
| `S` / `I` | **Prefijo de categoría** (1 letra) | `S` = startup, `I` = ICG. Determinado por la tabla donde vive la entidad (startups / icgs), no por el catálogo |
| `TTT` | **Tipo granular** (3 letras) | Subcategoría de segundo nivel, desde tabla de catálogo (§5.2) |
| `PPP` | **Prefijo telefónico del país** (3 dígitos) | Mismo criterio del código de eventos: `getPhonePrefix3` sobre el SSOT `COUNTRIES` de `countries.ts` — Colombia → `057`, Perú → `051`, México → `052`; sin país → `000`. El helper compositor normaliza tildes antes del lookup (matiz "Peru" de §1) |
| `AA` | **Año** (2 dígitos) | **CONFIRMADO por ti: año de REGISTRO EN EL SISTEMA** (fecha de formalización, `formalized_at`) |
| `NNNNN` | **Consecutivo a 5 dígitos, SEPARADO por tipo** | **CONFIRMADO por ti: dos secuencias independientes** — una para startups (S) y otra para ICG (I) — por año. Ver diseño del contador abajo. Nota 5 vs 6 dígitos en §9.1 |

**Contador separado por tipo (tu punto 2):** una sola tabla `entidad_codigo_counters` con clave compuesta que incluye el tipo — dos secuencias limpias sin duplicar infraestructura:

```sql
CREATE TABLE entidad_codigo_counters (
  tipo        CHAR(1)  NOT NULL CHECK (tipo IN ('S','I')),
  anio        SMALLINT NOT NULL,
  consecutivo INTEGER  NOT NULL DEFAULT 0,
  PRIMARY KEY (tipo, anio)
);
```

RPC atómica `next_entidad_consecutivo(p_tipo, p_anio)` con `INSERT ... ON CONFLICT (tipo, anio) DO UPDATE SET consecutivo = consecutivo + 1 ... RETURNING` — réplica exacta del patrón probado de eventos (`evento_codigo_counters` + `next_evento_consecutivo`), sin colisiones por concurrencia. Así, `S-...-26-00042` es la startup n.º 42 formalizada en 2026 y `I-...-26-00042` es la ICG n.º 42 — secuencias que no se cruzan.

### 5.2 Taxonomía de códigos de tipo (extensible)

**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). El prefijo `S`/`I` NO vive en esta tabla — lo aporta el compositor según la tabla de origen de la entidad.

```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 para colgar el código corto.
- **Startups** (prefijo `S`): `company_type` (enum `STARTUP`/`PYME`/`SCALEUP`) → fila del catálogo (`Startup`/`PyME`/`Scaleup`) → `codigo_corto`.
- **ICGs** (prefijo `I`): 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 sigla, §9.2):**

| 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 (formato nuevo)

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

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**: 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` y cada tabla consumiendo su propia secuencia (startups la S, ICGs la I).

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

- **PS y PI** (paneles de startup e ICG): 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 del sistema — para DESIGN.md — y el fix de eventos (tu punto 5)

> **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.

**Estado de cada identificador de eventos frente a la regla** (verificado en el código):

- El **código formateado** de eventos (`evento_codigo`) **cumple**: se genera únicamente en el submit final vía `next_evento_consecutivo`; el autosave de borradores lo deja en NULL (documentado en el propio código de `registro-evento/+page.server.ts`: "evento_codigo NO se genera en drafts").
- El **`codigo_unico`** de eventos **NO cumple**: es columna `SERIAL UNIQUE` (migración `049`), Postgres lo asigna **al insertar la fila — incluido el insert del borrador del autosave**. Un borrador abandonado quema un número.

**Por tu decisión, esto YA NO es deuda: es la Task 14 de este plan (Ola 5, la última).** Diseño del fix, basado en cómo se usa hoy `codigo_unico` (investigado archivo por archivo):

| Uso actual de `codigo_unico` | Archivo | Impacto del fix |
|---|---|---|
| Enlace público de compartir `/eventos/{codigo}` | `src/routes/eventos/[codigo]/+page.server.ts` → `getEventoPublicoByCodigo` | **Solo sirve eventos `APPROVED` no eliminados** — los códigos de borradores jamás se exponen públicamente. Anular el código de los borradores no rompe ningún enlace |
| Ficha/lookup por código | `src/lib/server/eventosPublicos.ts` (`.eq('codigo_unico', ...)` + `SELECT_COLS`) | Sin cambio: los eventos publicados conservan su código actual (no se renumera nada) |
| Mensaje de compartir por WhatsApp | `src/lib/utils/eventoShare.ts` (`codigoUnico` en `EventoShareData`) | Ya modela `codigoUnico: number \| null`; con el fix, los borradores lo tendrán en NULL — la UI debe ocultar/deshabilitar compartir en borradores (verificación en Task 14) |
| Listados Mis eventos / Aprobación admin | `dashboard/eventos/mios/+page.server.ts`, `dashboard/eventos/admin/aprobacion/+page.server.ts` | Ya mapean `(e.codigo_unico as number) ?? null` — toleran NULL |
| Duplicar evento | `api/eventos/[id]/duplicar/+server.ts` | Ya **omite** `codigo_unico` en la copia (comentario en el código); hoy el SERIAL le asigna uno al insertar — con el fix quedará NULL hasta el envío, que es lo correcto |
| `super_eventos.codigo_unico` | migración `049` (también SERIAL) | **Fuera de alcance**: los super eventos son admin-only y se crean directo, sin flujo de borrador — la regla "nada en borrador recibe código" no les aplica |

**El fix (detalle en Task 14):** (1) migración que quita el `DEFAULT nextval(...)` y el `NOT NULL` de `eventos.codigo_unico` conservando la secuencia existente, y anula (`NULL`) el `codigo_unico` de las filas en `DRAFT` (snapshot previo; seguro porque los borradores nunca son públicos); (2) el submit final de `registro-evento` asigna el `codigo_unico` desde la secuencia (RPC atómica), en el mismo punto donde hoy se genera `evento_codigo` — una sola vez, idempotente; (3) los eventos ya enviados/aprobados/rechazados **conservan su número actual** (cero renumeración → cero enlaces rotos); los números ya quemados por borradores viejos no se reciclan (la secuencia sigue donde va — es un identificador, no un censo).

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

---

## 6. Barrido de duplicados existentes

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

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 (14 tareas, 6 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 + 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 |
| 5 | 14 | — (técnicamente independiente; va al final por tu decisión) | No | **ALTO — Opus** (migración eventos) |

### 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) + re-confirmación de que `country` está en nombre oficial (0 filas con `char_length(country) <= 3` y country no vacío)
- [ ] Cero escrituras a la base: el script solo hace SELECT (revisión del código del script)

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

**Files:**
- Edit: `DESIGN.md` (regla "nada en borrador recibe código" del §5.6, con nota de que el `codigo_unico` de eventos se corrige en la Task 14 de este mismo plan — ya no es deuda)

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

### Task 3: Migración — índice único nuevo por país _(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)
- [ ] Índice nuevo creado y viejo eliminado; probado con psql: inserción duplicada (mismo usuario + nombre + país, formalizada) falla con 23505; mismo nombre en país distinto pasa; "Peru"/"Perú" cuentan como el mismo país (unaccent)
- [ ] Idempotencia probada: la migración corre dos veces sin error
- [ ] Rollback probado en local: restaura índice viejo y no pierde datos

(Sin data-fix de países: verificado que `country` ya guarda el nombre oficial — tu punto 6.)

### 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 + contadores por tipo + 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` con PK `(tipo, anio)` + RPC `next_entidad_consecutivo(p_tipo, p_anio)` atómica; probado con psql: dos llamadas concurrentes al mismo `(tipo, anio)` devuelven números distintos, y las secuencias S e I avanzan por separado
- [ ] Backfill completo: 100% de entidades formalizadas con código en formato `S-…`/`I-…`, 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 (S/I + TTT/PPP/AA/NNNNN, fallbacks OTR/000, tildes)

**Done when:**
- [ ] Toda formalización (público y dashboard) asigna código exactamente una vez; idempotente (segunda llamada no regenera); startups reciben prefijo `S-` y secuencia S, ICGs prefijo `I-` y secuencia I
- [ ] Fallbacks probados: ICG sin `icg_role` → `OTR`; sin país → `000`
- [ ] `PPP` replica el criterio de eventos con test que lo compara contra `getPhonePrefix3`, y el lookup del compositor es insensible a tildes: test explícito `"Peru"` (sin tilde) → `051` (hoy `getPhonePrefix3('Peru')` devuelve `000` — el helper lo corrige normalizando antes del lookup)
- [ ] 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 `S-…`/`I-…` y la unicidad (psql)

### Task 14: Eventos — `codigo_unico` solo al enviar, nunca en borrador _(Wave 5, la última — tu punto 5)_ — **ALTO RIESGO, manos Opus**

Corrige la violación de la regla "nada en borrador recibe código" descrita en §5.6, con el diseño verificado archivo por archivo en la tabla de esa sección.

**Files:**
- Create: `supabase/migrations/133_eventos_codigo_unico_on_submit.sql` (+ rollback): `ALTER TABLE eventos ALTER COLUMN codigo_unico DROP DEFAULT, DROP NOT NULL` (la columna es `SERIAL` = NOT NULL + DEFAULT nextval; la secuencia se conserva); `UPDATE eventos SET codigo_unico = NULL WHERE status = 'DRAFT'` (snapshot previo); RPC atómica `next_evento_codigo_unico()` sobre la secuencia existente
- Edit: `src/routes/registro-evento/+page.server.ts` — el submit final asigna `codigo_unico` vía la RPC en el mismo punto donde hoy genera `evento_codigo` (una sola vez, idempotente: si la fila ya tiene código, no se regenera); el autosave (insert y update de borrador) no lo toca
- Edit: UI de compartir (`EventRow` / `EventoDetailModal` / `eventoShare.ts` según corresponda) — ocultar/deshabilitar compartir cuando `codigoUnico` es NULL (borradores)

**Done when:**
- [ ] Un borrador nuevo por autosave queda con `codigo_unico` NULL y la secuencia NO avanza (psql: `last_value` de la secuencia igual antes y después del autosave)
- [ ] El submit final asigna `codigo_unico` exactamente una vez (segunda invocación no lo cambia — probado); duplicar un evento produce copia en borrador SIN código
- [ ] **Cero enlaces rotos**: muestra con psql de 10 eventos APPROVED — su `codigo_unico` es idéntico antes y después de la migración, y `curl` a `/eventos/{codigo}` de 3 de ellos devuelve 200 en preview
- [ ] Los borradores en "Mis eventos" no ofrecen compartir (captura Playwright); un evento aprobado sí, con la URL correcta
- [ ] Idempotencia y rollback de la migración probados; `super_eventos` intacto (fuera de alcance, sin flujo de borrador)

---

## 9. Lo único que falta confirmar (corto)

1. **5 vs 6 dígitos del consecutivo:** en tu punto 2 dijiste "a 5 dígitos" pero en un ejemplo escribiste `NNNNNN` (6). Este plan usa **5** (`00042`) — confirma que es 5, o se cambia a 6 antes de la migración (es un parámetro del compositor y del backfill; trivial antes de ejecutar, molesto después).
2. **Los 43 códigos cortos** (§5.2): revisa la tabla por si quieres ajustar alguna sigla (es solo cambiar la fila del seed antes de ejecutar).

Todo lo demás está decidido: año = registro en el sistema (tu 1), secuencias separadas S/I (tu 2), prefijo S/I (tu 3), correo de expiración a ambas partes (tu 4), fix del `codigo_unico` de eventos como Task 14 al final (tu 5), y sin data-fix de países (tu 6).

---

**Resumen:** el v3 cierra las 6 decisiones que tomaste sobre el v2. El código queda `S-TTT-PPP-AA-NNNNN` / `I-TTT-PPP-AA-NNNNN` con secuencias anuales independientes por tipo (contador atómico con clave tipo+año, patrón de eventos), año = registro en el sistema, y prefijo telefónico desde el SSOT `COUNTRIES` con lookup insensible a tildes. Desaparece el data-fix ISO2 (el supuesto era falso: el país ya está en nombre oficial en toda la data), el índice único por país se mantiene solo con la normalización unaccent existente, y el `codigo_unico` SERIAL de eventos entra como Task 14 en la última ola: se deja de asignar en el borrador del autosave y pasa al submit final, conservando intactos todos los números y enlaces públicos `/eventos/{codigo}` de los eventos ya publicados. 14 tareas en 6 olas, migraciones aditivas y reversibles con snapshot, alto riesgo con manos Opus y verificación con evidencia. Con tu OK final (y las dos confirmaciones menores de la §9) arrancamos por la Ola 0.
