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:

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):


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]

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:

-- 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:

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:

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:

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.

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):

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

5.5 Dónde se ve el código

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):

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.tsgetEventoPublicoByCodigo 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:


7. Columna "Código" en las consultas

Verificado el estado actual de ambas páginas:

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) ALTO — Opus (migraciones)
2 6, 7, 8 Wave 1 Medio — Opus
3 9, 10, 11, 12 Wave 2 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_roleOTR; 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.