# 18 — Problemas Conocidos

> Hallazgos del análisis del código, **no corregidos**. Este documento solo describe.
> Cada hallazgo indica el fichero y la línea o el método exacto para que pueda verificarse.
>
> **Clasificación:** 🔴 CRÍTICO · 🟠 ALTO · 🟡 MEDIO · 🔵 BAJO · ⚙️ DEUDA TÉCNICA

---

## Resumen

| Severidad | Cantidad |
|---|---|
| 🔴 Crítico | 6 |
| 🟠 Alto | 9 |
| 🟡 Medio | 11 |
| 🔵 Bajo | 7 |
| ⚙️ Deuda técnica | 14 |
| **Total** | **47** |

---

## 🔴 CRÍTICOS

### HR-SQL-1 — Las cláusulas WHERE no escapan los valores

**Ficheros:** `core/db/MysqlPDO.php::criteriaToSql()` (línea 254) · `core/Model.php` (todos los métodos de lectura)

```php
$condition .= " " . $criteria["name"];
$condition .= " " . $criteria["operator"];
$condition .= " " . $criteria["separatorValues"] . $criteria["value"] . $criteria["separatorValues"] . " ";
```

El valor se concatena **sin ningún escapado**. `insert()`, `update()` y `updateCriteria()` sí aplican `addslashes()` a los valores, pero **`criteriaToSql()` no**.

La única defensa es que quien construye el criterio castee la entrada. El proyecto ha ido introduciendo ese saneamiento (método privado `cleanText()`, duplicado en 21 controladores, y casts `(int)`), pero **no es sistemático ni verificable**.

**Impacto:** inyección SQL en cualquier punto que pase entrada de usuario a un criterio sin castear.
**Mitigación real:** sentencias preparadas en `criteriaToSql()`, o un escapado obligatorio en el punto de construcción.

---

### HR-SQL-2 — `criteriaExt`: el cliente puede inyectar criterios arbitrarios

**Fichero:** `core/ListaAjax.php::loadCriteria()` (línea 449)

```php
if (isset($_REQUEST['criteriaExt']) && !empty($_REQUEST['criteriaExt'])) {
    $this->Criteria = (array) ($_REQUEST['criteriaExt']);
}
```

El array de criterio llega **directamente de la petición** y se pasa a `criteriaToSql()`, que no escapa (HR-SQL-1).

**Alcance:** **todos** los endpoints `dataListAjax`, es decir, prácticamente todos los módulos.

**Agravante:** `'dataListAjax'` está en `Config::$actionsGeneral`, la lista de acciones exentas de permiso. **Cualquier usuario autenticado puede invocar el endpoint de datos de cualquier módulo**, tenga o no permiso `list` sobre él, e inyectar un criterio arbitrario.

**Impacto:** exfiltración de datos de cualquier tabla del sistema por parte de un usuario autenticado con el mínimo privilegio.

---

### HR-SQL-3 — El término de búsqueda se concatena sin escapar

**Fichero:** `core/ListaAjax.php::loadCriteria()` (línea ~497)

```php
$CadenaBusqueda .= $this->FieldsShow[$j]." LIKE '%".$SeparadorBusqueda[$z]."%' OR ";
```

`$SeparadorBusqueda` proviene de `$_REQUEST['search']['value']` partido por espacios. **Sin `addslashes` ni sentencias preparadas.**

**Alcance:** el buscador de todos los listados del sistema.

---

### HR-AUTH-1 — Contraseñas en MD5 sin sal

**Fichero:** `core/helpers/PassHelper.php`

```php
public static function encode($pass)        { return md5($pass); }
public static function verify($pass, $hash) { return md5($pass) == $hash; }
```

MD5 es un algoritmo **roto para contraseñas**: es rápido de calcular (miles de millones de intentos por segundo en GPU), no tiene sal y existen tablas rainbow públicas. Además la comparación con `==` no es de tiempo constante.

**Agravante — la incoherencia interna:** `LineaEticaCasosModel` **sí** usa `password_hash(PASSWORD_DEFAULT)` y `password_verify()` para el PIN de seguimiento de denuncias. El mecanismo correcto ya está en el proyecto, pero no se aplica al login.

**Facilitador de la corrección:** `usuarios.Contrasena` y `colaboradores.Contrasena` son `varchar(500)`. **La migración a `password_hash()` no requiere cambio de esquema.**

---

### HR-LEAK-1 — La traza de excepción se imprime al usuario, siempre

**Fichero:** `core/AutoLoad.php` (líneas ~210-215)

```php
try {
    call_user_func(array($app, 'process'));
} catch (Error $exception) {
    echo " {$exception}";
} catch (Exception $exception) {
    echo " {$exception}";
}
```

**No depende de `APP_DEBUG`.** En producción, cualquier excepción no capturada muestra al usuario la traza completa: rutas absolutas del servidor, nombres de clase, números de línea y, según el caso, fragmentos de SQL con datos.

---

### HR-EVAL-1 — `eval()` sobre datos de la base en el motor de nómina

**Fichero:** `app/models/NominaCalculoModel.php::safeEvalFormula()` (línea 246)

```php
$expr = strtoupper((string)$formula);
foreach ($variables as $key => $value) {
    $expr = preg_replace('/\b'.preg_quote(strtoupper($key),'/').'\b/', (string)((float)$value), $expr);
}
if (!preg_match('/^[0-9\.\+\-\*\/\(\)\s]+$/', $expr)) { return 0.0; }
$result = eval('return ' . $expr . ';');
```

La lista blanca de caracteres es estricta y bloquea la ejecución de código arbitrario en la práctica. **Aun así**, es un `eval()` alimentado por la columna `nomina_conceptos.Formula`, editable desde la interfaz por cualquier usuario con permiso sobre el módulo de nómina. Cualquier relajación futura del regex se convierte en ejecución remota de código.

**Alternativa:** una biblioteca de evaluación de expresiones matemáticas, o un intérprete propio con parser.

---

## 🟠 ALTOS

### HR-CSRF-1 — `CSRF_STRICT` no tiene efecto

**Ficheros:** `app/config/Config.php` (líneas 43, 162) · `.env.example`

```bash
grep -rF '$CSRF_STRICT' app core services
# → solo app/config/Config.php
```

La variable se declara y se rellena desde el `.env`, pero **ninguna comprobación de CSRF la consulta**. El `.env.example` la recomienda en `true` (*"Recomendado true: exige token CSRF en todas las peticiones POST"*), lo que da una **falsa sensación de seguridad**.

---

### HR-CSRF-2 — La validación de CSRF no está centralizada

**Fichero:** `core/Controller.php::process()`

El framework solo impone CSRF en:
1. La acción `remove` (para todos los controladores).
2. `Model::save()` cuando la petición es POST.

**Toda otra mutación** (`aprobar`, `rechazar`, `cancelar`, `cambiarEstado`, `publicar`, `archivar`, `asignar`, `entregar`, `cerrar`, …) depende de que el autor recuerde escribir la comprobación a mano. Es un patrón repetido decenas de veces y fácil de olvidar.

**Mitigación parcial existente:** la cookie de sesión es `SameSite=Strict`, lo que bloquea las peticiones cross-site. No protege frente a un XSS almacenado.

---

### HR-IDOR-1 — No hay verificación centralizada de propiedad de registro

Ninguna capa comprueba que el registro al que se accede pertenezca al usuario de la sesión.

`Controller::$PUBLIC_COLLABORATOR_ROUTES` autoriza **la ruta**, no **el registro**. Que un colaborador pueda ejecutar `?c=Tickets&a=ver` no significa que solo pueda ver sus propios tickets: eso depende de que la acción aplique `listarConFiltros($filtros, $soloColaboradorId)`.

**Existe un mecanismo parcial**: el permiso con valor `2` ("solo mis registros") añade `WHERE UsuarioRegistro = <usuario>`, **pero solo lo implementa `ListaAjax`**. Una acción `view`/`edit` invocada con un `Id` ajeno no aplica esa restricción.

`tests/MutationSecurityRegressionTest::testUserProfileOperationsValidateOwnershipAndUploadedImages` cubre el caso de `UsuariosController`, pero no el resto.

---

### HR-PERM-1 — `dataListAjax` está exento de permisos

**Fichero:** `app/config/Config.php`

```php
public static $actionsGeneral = array('dataListAjax', 'testDB');
```

`Controller::validateAccess()` concede `ACCESS` a cualquier acción de esta lista **para cualquier usuario autenticado**, sin consultar la tabla `permisos`.

Combinado con HR-SQL-2 (`criteriaExt`), un colaborador del portal puede consultar el listado de nómina, salarios o casos de la línea de ética.

`'testDB'` también está exenta: es la acción de `Controller::testDB($modelName)` que renderiza `_test/db`.

---

### HR-PERM-2 — El portal concede permisos por código, eludiendo la base de datos

**Fichero:** `app/models/ColaboradoresModel.php::validateUser()` (líneas 464-600+)

Tras cargar los permisos del rol 5 desde la base, el método **sobrescribe la sesión** con asignaciones incondicionales:

```php
$_SESSION[APP_ID]['Permissions']['Colaboradores']['list']  = 1;
$_SESSION[APP_ID]['Permissions']['Colaboradores']['view']  = 1;
$_SESSION[APP_ID]['Permissions']['Colaboradores']['edit']  = 1;
// … y bloques if(!isset(…)) para ~12 módulos más
```

**Consecuencias:**
- Quitar un permiso al rol 5 en la base **no tiene efecto** sobre los asignados incondicionalmente.
- `Colaboradores` es el mismo `$MODULE_NAME` que el CRUD administrativo de colaboradores. Sin la lista blanca de rutas, un colaborador tendría acceso al CRUD completo.
- La única barrera efectiva es `Controller::$PUBLIC_COLLABORATOR_ROUTES`, una lista **hardcodeada en `core/Controller.php`**: mezcla la política de autorización con el framework.

---

### HR-TX-1 — Los flujos multi-tabla no usan transacciones

`grep -rn "beginTransaction"` sobre `app/`, `core/` y `services/` devuelve **una sola ocurrencia**: `ColaboradoresModel::deleteCascade()`.

Flujos afectados:

| Flujo | Riesgo | Fichero |
|---|---|---|
| Aprobar vacaciones | Cambia el estado y **después** descuenta el saldo. Si lo segundo falla, la solicitud queda aprobada sin descontar días. El código lo reconoce: *"La solicitud cambió de estado, pero hubo un problema actualizando el saldo"* | `VacacionesController::aprobarSolicitudInterna()` |
| Aprobar beneficio | Cambia el estado y **después** consume cupo y presupuesto | `BeneficiosSolicitudesController` + `BeneficiosModel::aplicarConsumoAprobacion()` |
| Recalcular nómina | Guarda la liquidación, **borra** el detalle y lo regenera fila a fila | `NominaCalculoModel::recalcularColaborador()` |
| Contratar candidato | Cambia el estado, escribe historial y actualiza el colaborador | `RecruitmentApplicationsModel::moverEtapa()` |
| Desembolsar adelanto | Cambia el estado, crea desembolso, plan y N cuotas | `AdelantosNominaController::registrarDesembolso` |

---

### HR-RACE-1 — Consumo de cupo de beneficios sin bloqueo

**Fichero:** `app/models/BeneficiosModel::aplicarConsumoAprobacion()`

```php
$fresh = static::getById($id);                       // READ
if (!static::tieneDisponibilidad($fresh, $monto)) {  // CHECK
    return false;
}
// … calcula el nuevo cupo y presupuesto …
return static::editFromParameters(…);                // WRITE
```

Patrón read-check-write **sin transacción ni `SELECT … FOR UPDATE`**. Dos aprobaciones concurrentes del último cupo pueden pasar ambas y dejar `CupoUsado > CupoTotal`.

El método hace lo correcto al releer el registro fresco, pero eso no basta sin bloqueo.

---

### HR-014 — El DocumentRoot puede exponer el código fuente

**Fichero:** `.htaccess` de la raíz (documentado en el propio fichero)

> *NOTA: esto es una mitigación, no la solución. La corrección real es que el DocumentRoot del vhost apunte a `public/` y no a la raíz del proyecto; mientras no sea así, `app/`, `core/`, `database/` y `.git/` siguen siendo alcanzables.*

Si el DocumentRoot apunta a la raíz, `.htaccess` bloquea `.env`, `.gitignore`, `composer.json/lock` y `sftp.json`, y desactiva los listados. **No bloquea `app/`, `core/`, `database/` ni `.git/`.**

**Complicación:** mover el DocumentRoot a `public/` deja fuera `files/` (que el layout referencia como `URL::base_url().'/../files/…'`) y `core/ajax.js`. Requiere un `Alias`.

---

### HR-DEAD-1 — 20 clases referenciadas que no existen

Ver el inventario completo en [12_INTEGRATIONS.md](12_INTEGRATIONS.md) §12.

```
AsignaturasModel · DebugModel · DirectoresEvaluacionesModel · DirectoresModel
DirectorioActivoModel · DocentesAsignaturasModel · DocentesEvaluacionesModel
DocentesModel · EstudiantesAsignaturasModel · EstudiantesEvaluacionesModel
EstudiantesModel · EstudiantesRespuestasModel · FacultadesModel · LaborDocenteModel
OdsDocentesModel · PostulacionesModel · ProduccionIntelectualModel · ProgramasModel
ProyectosDocentesModel · ResumenEvaluacionModel
```

Cualquier ruta que las alcance produce **error fatal**, y por HR-LEAK-1 la traza se muestra al usuario.

**Caso más peligroso — `DirectorioActivoModel`:** con `DIRECTORIO_ACTIVO=true`, `UsuariosModel::validateUser()` la invoca cuando la contraseña local falla → error fatal en el flujo de autenticación.

---

## 🟡 MEDIOS

### HR-CONF-1 — Cinco variables de configuración sin efecto

Verificado con `grep -rF` sobre `app/`, `core/` y `services/`:

| Variable | Documentada como | Efecto real |
|---|---|---|
| `CSRF_STRICT` | *"Exige token CSRF en todas las peticiones POST"* | **Ninguno** |
| `IP_BLOCKING` | Bloqueo por IP (con tabla `ips_autorizadas` y CRUD completos) | **Ninguno**: nada comprueba la IP |
| `NO_COPY` | Impedir copiar contenido | **Ninguno** |
| `CAMBIO_ROL` | Cambio de rol en caliente | **Ninguno** |
| `LOGIN_AUTOMATICO` | Login automático | **Ninguno** |
| `MAIL_DIARIOS_MAX` / `$correos_diarios` | Tope diario de correos | **Ninguno** |
| `APP_NO_PAYMENT` / `$noPayment` | Cliente moroso | **Ninguno** |

Cada una de ellas es una **falsa expectativa** para quien configura el sistema.

---

### HR-DOC-1 — La documentación técnica preexistente describe otro producto

**Ficheros:** `docs/01_GETTING_STARTED.md` … `docs/06_STANDARDS.md` y `docs/AI_MODULE_DEVELOPMENT_GUIDE.md` (3 543 líneas)

Estos documentos describen una arquitectura que **no existe en este repositorio**:

| Documentado | Realidad |
|---|---|
| `core/Request.php`, `core/Response.php` | ❌ No existen |
| `core/MailService.php`, `core/EventDispatcher.php` | ❌ No existen |
| `core/TenantContext.php`, `core/App.php` | ❌ No existen |
| `MiddlewarePipeline`, `AuthMiddleware`, `CsrfMiddleware`, `RateLimitMiddleware` | ❌ No existen |
| Traits `HasValidation`, `HasTenant`, `HasCache`, `HasAuditLog` | ❌ `grep "trait Has"` → 0 resultados |
| `app/config/Modules.php` con `$registry` | ❌ No existe |
| `bin/make module Producto` | ❌ Existen `bin/make-migration` y `bin/make-seeder` |
| `appId = 'KleePlanner2'`, `MODULES_OVERRIDE`, `TENANTS` | ❌ Es otro producto |
| `Model::find()`, `findAll()`, `deleteFromParameters()` | ❌ No existen en `core/Model.php` |
| `$_SERVER['KLEE_CSRF_VALIDATED']` | ❌ No se usa en ninguna parte |

**Riesgo:** una IA o un desarrollador nuevo que lea esos documentos escribirá código que no compila ni se ejecuta.

**Recomendación:** archivarlos o marcarlos como obsoletos, apuntando a `docs/knowledge/`.

`docs/guias_modulos/` (manuales funcionales) y `database/README.md` **sí son correctos y vigentes**.

---

### HR-VIEW-1 — `vista_colaboradores` expone la columna `Contrasena`

**Verificado:** `SHOW CREATE VIEW vista_colaboradores` incluye `c.Contrasena AS Contrasena`.

`ColaboradoresModel::getAll()`, `getById()` y `getByCriteria()` están **sobreescritos para leer siempre de la vista**, y su uso habitual es `array('*')`. El hash de contraseña acaba en memoria en cualquier listado de colaboradores, y en la respuesta de cualquier endpoint que serialice la fila completa.

---

### HR-VIEW-2 — El docblock de `ColaboradoresModel` describe columnas inexistentes

**Fichero:** `app/models/ColaboradoresModel.php` (líneas ~390, ~400)

```php
/**
 * Sobrescribe getAll para leer siempre desde la vista.
 * La vista incluye aliases de compatibilidad (Nombre, Apellido, NoIdentificacion, Cargo, Dependencia).
 */
```

**Falso.** Las únicas columnas de `vista_colaboradores` con esos nombres son `Nombres`, `Apellidos` e `IdCargo`; el único alias añadido es **`NombreCompleto`**. No existen `Nombre`, `Apellido`, `NoIdentificacion`, `Cargo` ni `Dependencia`.

Cualquier criterio que filtre por esos alias falla con `Column not found`.

---

### HR-VIEW-3 — Las 62 vistas usan `DEFINER = root@localhost`

Todas se crean con `SQL SECURITY DEFINER` y ese definidor. Al restaurar un volcado en un servidor donde `root@localhost` no exista, **todas las vistas fallan** con `ERROR 1449`, y con ellas la mayor parte de la aplicación.

---

### HR-SESS-1 — `logout` no destruye la sesión

**Fichero:** `app/controllers/LoginController.php::logoutAction()`

```php
public function logoutAction()
{
    unset($_SESSION[Controller::getAppId()]);
    ROUTER::redirect_to_action();
}
```

No llama a `session_destroy()`, no regenera el identificador y no invalida la cookie. La rama `$_SESSION['public']` (usada por `Model::log()`) sobrevive al cierre de sesión.

---

### HR-RATE-1 — El rate limiter vive en la sesión

**Fichero:** `core/RateLimiter.php`

```php
$_SESSION[Controller::getAppId()][self::SESSION_KEY]
```

Un atacante que descarte la cookie en cada intento **reinicia el contador**. No protege contra fuerza bruta automatizada.

**Mitigación posible:** almacenar los intentos en base de datos o en fichero, indexados por IP + usuario.

---

### HR-NOM-1 — `BaseCalculo = 'NETO'` se comporta como `TOTAL_DEVENGADO`

**Fichero:** `app/models/NominaCalculoModel::resolverBase()`

```php
if ($baseCalculo === 'TOTAL_DEVENGADO') { return (float)$totalDevengadoParcial; }
if ($baseCalculo === 'NETO')            { return (float)$totalDevengadoParcial; }  // ← idéntico
```

Un concepto configurado con base `NETO` produce un valor **incorrecto**: usa el devengado parcial en lugar del neto.

---

### HR-NOM-2 — El resultado de la nómina depende del orden de los conceptos

`BaseCalculo = 'TOTAL_DEVENGADO'` usa el acumulado **parcial** hasta ese punto del bucle. El resultado depende del orden en que `NominaConceptosModel::listarActivos()` devuelva las filas, y **`nomina_conceptos` no tiene columna de orden explícita**.

Cambiar el orden de inserción de los conceptos cambia el resultado de la liquidación.

---

### HR-CSP-1 — La CSP es incoherente con los recursos que se cargan

**Fichero:** `core/AutoLoad.php`

```
Content-Security-Policy: default-src 'self' 'unsafe-inline' data:;
```

- **No declara** `fonts.googleapis.com`, `fonts.gstatic.com` ni `cdn.datatables.net`, que sí se cargan (fuente Poppins y traducción de DataTables).
- Incluye `'unsafe-inline'`, necesario porque `ListaAjax` genera `<script>` inline, lo que **anula buena parte de la protección contra XSS**.

---

### HR-XSS-1 — El escapado en vistas es inconsistente

No hay escapado automático: `View::render_view()` hace `extract($parameters)` y cada vista es responsable.

Las vistas nuevas usan `htmlspecialchars()`; **las antiguas no siempre**. Ejemplo verificable: `app/views/ips_autorizadas/_form.php` imprime `$model->Nombre->getValue()` y `$model->Ip->getValue()` **sin escapar**, mientras que `app/views/competencias/_form.php` sí escapa.

Combinado con HR-CSP-1 (`'unsafe-inline'`), un XSS almacenado sería explotable.

---

### HR-DEMO-1 — El modo demo escribe en la base real y suplanta identidades

**Fichero:** `app/controllers/DemoController.php`

`?c=Demo&a=index` (acción `'*'`) encadena `seedDemoData()` —que **escribe en la base de datos real**— con `bootDemoSession()` —que **crea una sesión suplantando a un colaborador real**—. En modo demo, `validateAccess()` concede `ACCESS` a cualquier acción no sensible de 13 módulos **sin comprobar sesión ni permisos**.

**Mitigación aplicada (HR-007):** requiere `DEMO_MODE_ENABLED=true`, y la doble condición en `Controller::__construct()` hace que desactivar la variable invalide inmediatamente las sesiones de demo existentes.

**Riesgo residual:** si esa variable se activa por error en producción, el sistema queda abierto.

---

## 🔵 BAJOS

### HR-LAYOUT-1 — `impresionesPOS` referencia un fichero inexistente

`Config::$layout['impresionPOS'] = 'impresionesPOS'`, pero `app/layouts/impresionesPOS.php` **no existe**. Ningún controlador lo usa hoy; el primero que lo haga fallará.

---

### HR-CFG-1 — Periodo por defecto codificado a fuego

```php
'Periodo' => array(… 'valueDefault' => 29 …)
```

Un Id concreto. En una instalación nueva ese periodo puede no existir. Solo se usa si `PeriodosModel::getAllFiltersSesion()` no devuelve opciones, pero es un valor que no debería estar en el código.

---

### HR-CFG-2 — Clave de configuración duplicada

`configuraciones` contiene **`ReclutamientoContratoExigeFirma`** y **`RecruitmentContratoExigeFirma`**, ambas con valor `1`. Cambiar solo una produce comportamiento inconsistente según qué punto del código lea cuál.

---

### HR-ASSET-1 — `quick-search.js` duplicado y divergente

`public/js/quick-search.js` y `public/assets/js/quick-search.js` tienen **el mismo tamaño (11 626 B) pero distinto contenido** (md5 distinto). Solo el primero está referenciado por los layouts. El segundo es una copia huérfana que puede confundir en el mantenimiento.

---

### HR-ASSET-2 — JavaScript servido desde fuera de `public/`

`core/ajax.js` y `core/check_sessions.js` viven en `core/`. Para servirlos por HTTP, el DocumentRoot tiene que estar en la raíz del proyecto, lo que **contradice directamente la recomendación de HR-014**.

---

### HR-CLASS-1 — Clase de controlador vacía

`app/controllers/TiposContratoAdministrativoController.php` tiene **7 líneas y ninguna acción**, pero sí tiene entrada en la tabla `permisos`.

---

### HR-NAME-1 — Métodos estáticos con sufijo `Action`

`DemoController::isSensitiveAction()` y `shouldBlockAction()` son helpers estáticos, no acciones de controlador. El despachador los interpretaría como acciones (`?c=Demo&a=isSensitive`), aunque quedan denegados por no estar en `loadAccessControl()`. Es una colisión de nomenclatura a evitar.

---

## ⚙️ DEUDA TÉCNICA

### DT-1 — Legado de un producto de evaluación docente universitaria

El repositorio conserva código completo de otro producto:

| Elemento | Tamaño |
|---|---|
| `SincronizacionController` | 1 255 líneas, 17 acciones, casi todas inalcanzables |
| `AjaxController`: `getDocente`, `getDocentes`, `getProductosIntelectuales`, `getResumen`, `update` | 5 acciones sin declarar |
| `NotificacionesController`: `estudiantes`, `docentes`, `directores` | 3 acciones |
| `AlertasController`, `PublicModel`, `app/views/alertas/view.php` | Referencias a modelos inexistentes |
| `app/views/public/manual.php` | *"Bienvenido al sistema de gestión docente"*, tutoriales de Estudiantes/Docentes/Directores, con `getAppId() === "klee_compensar_escuela"` codificado a fuego |
| `app/views/perfil_cargo/` (5 vistas) | Sin controlador |
| `app/views/_test/`, `app/views/test/`, `TestController` | Herramientas de prueba |
| `app/config/Localidades.php`, `Sedes.php`, `TiposSede.php`, `GeneralDataArray.php` | Catálogos estáticos residuales |
| `Config::$classesGeneral`: `CarritoFacturacion`, `CarritoDevolucion`, `CarritoCotizacion` | Residuos de un producto de facturación |
| `usuarios`: columnas `Categoria`, `Dependencia`, `Sede`, `Firma` | Del producto anterior |
| `app/layouts/*/developr*.php` | Plantilla anterior (hay un test que impide usarla) |
| Tablas `graficas` y `metas_ciclos` | Vacías, sin modelo ni uso |
| `core/Instalador.php`, `core/Migracion.php`, `core/Htaccess.php` | Instalador incompleto (`//TODO pendiente terminar instalador`) |

---

### DT-2 — Doble nomenclatura en los modelos de reclutamiento

Nueve modelos existen dos veces: `Recruitment*Model` (canónico, con la tabla) y `Reclutamiento*Model` (alias de 5 líneas). Las tablas ya se renombraron al español en las migraciones 000027 y 000037, pero las clases no. Duplica la superficie de mantenimiento.

---

### DT-3 — Dos estilos de clave foránea

`IdColaborador` (módulos antiguos) vs `ColaboradorId` (módulos nuevos). Afecta a más de 20 tablas. Cualquier consulta requiere comprobar antes qué estilo usa la tabla. Unificarlo exige una migración amplia y coordinada.

---

### DT-4 — `cleanText()` duplicado 21 veces

Método privado idéntico en: `AdelantoPoliticasController`, `AdelantosNominaController`, `AdelantosNominaPortalController`, `BeneficiosAsignacionesController`, `BeneficiosController`, `BeneficiosPortalController`, `BeneficiosSolicitudesController`, `CapacitacionController`, `CapacitacionInscripcionesController`, `CapacitacionLeccionesController`, `CapacitacionPortalController`, `CentroAyudaController`, `ComunicacionInternaController`, `LineaEticaController`, `LineaEticaPublicaController`, `MensajesInternosController`, `PlanesCarreraController`, `PotencialesController`, `ReconocimientosController`, `SucesionController`, `TicketsController`.

Debería estar en `core/` (por ejemplo, en `TextHelper`). Su duplicación significa que **una corrección de seguridad hay que aplicarla 21 veces**.

---

### DT-5 — Cuatro contratos distintos para las validaciones de negocio

| Contrato | Módulo |
|---|---|
| `array('ok' => bool, 'errores' => [])` | Adelantos, beneficios |
| `array('ok' => bool, 'errors' => [])` *(en inglés)* | Metas |
| `array(bool, string)` con `list()` | Vacaciones |
| `true \| string` | `RecruitmentApplicationsModel::validarContratacion()` |

---

### DT-6 — Multi-tenancy a medias

`core/Model.php` tiene los ganchos `hasTenantColumn()` y `appendTenantCriteria()`, y `saveOnCreate()`/`saveOnUpdate()`/`loadById()`/`deleteById()` los invocan. **Ambos devuelven siempre `false` / el criterio sin cambios**, y ningún modelo los sobreescribe.

En paralelo, varias tablas tienen columna `CompanyId` (`reclutamiento_vacantes`, `reclutamiento_candidatos`, `reclutamiento_postulaciones`, `etapas_embudo_vacante`, `reglas_filtro_vacante`, `planes_desarrollo`, `acciones_desarrollo`, …). El valor se toma de `$_SESSION[…]['User']['CompanyId'] ?? 1`, columna que **no existe en la tabla `usuarios`**: siempre resulta `1`.

Hay tres mecanismos de multi-tenant a medio construir que no se comunican entre sí.

---

### DT-7 — Caché implementada y nunca activada

`core/Cache.php` está completa (TTL, namespaces, invalidación) y `core/Model.php` la integra en `getById`, `getByCriteria` y `getAll` bajo `$CACHE = true`. **Ningún modelo lo activa.** Solo `AsistenteIAService` usa `Cache` directamente.

---

### DT-8 — Auditoría implementada y casi nunca activada

`Model::log()` registra el diff completo en `log_modules`, pero requiere `$LOG = true` en el modelo. **Solo lo tienen `ColaboradoresModel` y `UsuariosModel`.** Los 125 modelos restantes no generan auditoría, incluidos los de nómina, adelantos, beneficios y línea de ética.

---

### DT-9 — Las tablas de auditoría crecen sin límite

`log_acceso`, `log_urls`, `log_modules` y `bitacora_auditoria` no tienen política de retención ni tarea de depuración. `log_modules` guarda el JSON completo del antes y el después de cada cambio.

---

### DT-10 — Sin tareas programadas de negocio

El único cron es el envío de correo. No hay proceso automático para: marcar `capacitacion_inscripciones` como `vencido`, recalcular el SLA de tickets abiertos, generar saldos de vacaciones al cambiar de año, alertar de contratos por vencer, ni depurar logs.

---

### DT-11 — `View::render_view()` instancia un controlador de más

```php
$controllerTemp = ucfirst($controllerName)."Controller";
$appTemp = new $controllerTemp;
$layoutName = $appTemp->getLayout();
```

Solo para preguntar el layout, se construye un segundo controlador, lo que reejecuta `loadPermission()`, `loadSystemUser()`, `loadAccessControl()` y `getTabs()`. Coste real en cada renderizado.

---

### DT-12 — Cobertura de pruebas mínima y sin lógica de negocio

13 ficheros PHPUnit, todos de **contrato y análisis estático** (semillas, layouts, regresiones de seguridad). **No hay pruebas de:** cálculo de nómina, cálculo de talento, reglas de metas, reglas de vacaciones, elegibilidad de beneficios, SLA de tickets, transiciones de estado.

No hay `phpunit.xml`, ni análisis estático (PHPStan/Psalm), ni linter, ni CI.

---

### DT-13 — Estilo de código heterogéneo

`array()` vs `[]`; tipado de parámetros solo en el código nuevo; sin `declare(strict_types)`; sin namespaces; nomenclatura de vistas mezclada (`list.php`/`create.php` en los módulos antiguos, `index.php`/`crear.php` en los nuevos). Comentarios en español e inglés.

---

### DT-14 — `docs/` excluido del control de versiones

`.gitignore` contiene `docs/**/*.md`. **Toda la documentación, incluida esta base de conocimiento, queda fuera del repositorio.** Requiere `git add -f` o una excepción explícita en `.gitignore`.

---

## Hallazgos de la auditoría previa ya corregidos

El código conserva las referencias. Buscar `Fase 0 auditoría`.

| Id | Descripción | Corrección |
|---|---|---|
| **HR-007** | `?c=Demo&a=index` sembraba datos y creaba una sesión suplantada sin ninguna condición | Requiere `DEMO_MODE_ENABLED=true`, con doble comprobación en `Controller::__construct()` |
| **HR-008** | Tres acciones de `ColaboradoresController` estaban declaradas `'*'` | Cambiadas a `'@'` |
| **HR-010** | Una acción de `EvaluacionesController` estaba declarada `'*'` | Cambiada a `'@'` |
| **HR-014** | El DocumentRoot en la raíz expone el código | ⚠️ **Mitigado con `.htaccess`, no resuelto.** Sigue siendo el hallazgo HR-014 activo |
| **HR-025** | Los endpoints de cron eran `'*'` sin comprobación: cualquiera podía provocar envío masivo | `guardCronAccess()` con `CRON_TOKEN` + permiso `send` |
| **HR-026** | Se leía `EnvioMaximoNotificaciones` y se descartaba, fijando el tope en 2 o 4 correos | Ahora se respeta el valor configurado |

**El documento de auditoría no está en el repositorio** → `[NO DETERMINADO EN EL CÓDIGO]`. Las referencias sugieren que hay al menos 26 hallazgos numerados, de los cuales solo 6 dejaron rastro en el código.

---

## Priorización sugerida

| Prioridad | Hallazgos | Justificación |
|---|---|---|
| **1** | HR-SQL-2, HR-PERM-1 | Combinados, permiten a cualquier usuario autenticado exfiltrar cualquier tabla. Corregir `criteriaExt` y sacar `dataListAjax` de `$actionsGeneral` es un cambio pequeño y de alto impacto |
| **2** | HR-LEAK-1 | Una línea: no hacer `echo` de la excepción salvo con `APP_DEBUG` |
| **3** | HR-AUTH-1 | Migrar a `password_hash()`. No requiere cambio de esquema. Se puede hacer con rehash progresivo en el login |
| **4** | HR-SQL-1, HR-SQL-3 | Escapado en `criteriaToSql()` — cambio contenido en un único método |
| **5** | HR-DEAD-1, DT-1 | Eliminar el legado docente reduce la superficie de ataque y el ruido |
| **6** | HR-TX-1, HR-RACE-1 | Transacciones en los cinco flujos afectados |
| **7** | HR-CONF-1, HR-DOC-1 | Corregir la documentación y las variables que engañan |
| **8** | HR-CSRF-2, HR-IDOR-1 | Centralizar CSRF y propiedad de registro |

---

## Documentos relacionados
- [10_BUSINESS_LOGIC.md](10_BUSINESS_LOGIC.md)
- [16_TROUBLESHOOTING.md](16_TROUBLESHOOTING.md)
- [17_AI_CONTEXT.md](17_AI_CONTEXT.md)
