# IMPLEMENTACIÓN — Recepción de la réplica de catálogos de tarifa (Fase 1)

**Proyecto:** CL032BACK (portal público) · base `asturias_api`
**Emisor:** CL034 (`cl038reservacionesv2`), Fases 2–5. **Todavía no existe.**
**Propuesta de referencia:** `../../CL034RESERVACIONESV2/docs/PROPUESTA_REPLICACION_CATALOGOS_PORTAL.md`
**Estado:** Fase 1 **terminada y probada**. Canal **apagado** (`REPLICACION_CATALOGOS_HABILITADA=false`).
**Fecha:** 2026-08-25 · Entorno: LOCAL / pre-producción.

> **§3 de este documento es el contrato que la Fase 2 de CL034 tiene que implementar byte por
> byte.** Si esa fase se escribe sin leerlo, el analizador de la Fase 5 va a reportar divergencia
> perpetua sobre catálogos que en realidad están idénticos. Hay un vector de prueba dorado en
> §3.5 para validarlo sin adivinar.

---

## 1. Qué se construyó

### 1.1 Archivos nuevos

| Archivo | Qué es |
|---|---|
| `app/Library/FSG/CanonicalizadorCatalogo.php` | **El contrato.** Normalización, orden y hash canónico. Congelado. |
| `app/Services/Reservaciones/AplicarCatalogoPrivadoService.php` | Toda la lógica: validaciones, cortes, diff, escritura, sello, `estado`. |
| `app/Http/Controllers/Reservaciones/CatalogoController.php` | Dos métodos delgados. Traduce el resultado a HTTP. |
| `app/Http/Requests/Reservaciones/Catalogo/AplicarCatalogoFormRequest.php` | Forma del payload. |
| `app/Exceptions/Reservaciones/CatalogoRechazadoException.php` | Rechazo total con motivo + código HTTP. |
| `app/Models/Modulos/Reservacion/CatalogoReplicacionEstado.php` | Fila única del sello; ancla de concurrencia. |
| `routes/modulos/reservaciones/catalogo.php` | Las dos rutas, dentro del `if` del interruptor. |
| `database/sql/rsv_catalogoreplicacionestado.sql` | DDL manual. |
| `database/migrations/2026_08_25_120000_create_rsv_catalogoreplicacionestado_table.php` | Migración equivalente. |

### 1.2 Archivos modificados

| Archivo | Cambio |
|---|---|
| `config/replicacion.php` | Bloque `catalogos` **hermano** de `inversa` (no dentro). Tres llaves. |
| `.env.example` / `.env` | `REPLICACION_CATALOGOS_HABILITADA=false` + `..._LOCK_TIMEOUT` + `..._HISTORIAL`. |
| `bootstrap/app.php` | Registro del archivo de rutas y `reservaciones/catalogo/*` en `shouldRenderJsonWhen`. |
| `app/Library/FSG/MonitorReservaciones.php` | Incidencia **`R18`** + su título. |
| `docs/replicacion/09-OBSERVABILIDAD.md` | Catálogo ampliado a `R1…R18` (se documentaron también `R16` y `R17`, que faltaban). |

**No se tocó** `routes/modulos/reservaciones/replicacion.php`, ni ninguna de las cinco tablas de
catálogo, ni nada del canal de reservaciones.

### 1.3 Rutas

```
POST /reservaciones/catalogo/aplicar   → CatalogoController@aplicar   seguridadEscritura:cuerpo
GET  /reservaciones/catalogo/estado    → CatalogoController@estado    seguridadEscritura
```

Ambas dentro de `throttle:` con `config('sincronizacion.rate_limit')` y dentro de
`if (config('replicacion.catalogos.habilitada'))`.

**Nombre del controlador — desviación deliberada del prompt.** El prompt pedía
`CatalogoReplicacionController`, pero `ARQUITECTURA_GENERAL §5.2` deriva el nombre del
controlador del **submódulo de la ruta**, que aquí es `catalogo`. Se usó `CatalogoController`
para que ruta, nombre de ruta y método coincidan exactamente como manda §4.4.4. Las URL no
cambian, así que el contrato con CL034 es el mismo.

**Sin `permiso:` — y no es una omisión.** §4.4.3 exige `permiso:` para rutas de pantalla, donde
hay un usuario con perfil. Aquí no hay usuario: hay un servidor. El control de acceso es la firma
HMAC + la allowlist ZeroTier, igual que en los otros dos canales máquina-a-máquina del proyecto.

---

## 2. DDL aplicado

```sql
CREATE TABLE IF NOT EXISTS `rsv_catalogoreplicacionestado` (
  `idCatalogoReplicacionEstado`        int         NOT NULL AUTO_INCREMENT,
  `catalogoReplicacionEstadoHash`      char(64)    NULL DEFAULT NULL,
  `catalogoReplicacionEstadoVersiones` json        NULL,
  `catalogoReplicacionEstadoFecha`     datetime(6) NULL DEFAULT NULL,
  `catalogoReplicacionEstadoAnioDesde` smallint    NULL DEFAULT NULL,
  `catalogoReplicacionEstadoResumen`   json        NULL,
  `catalogoReplicacionEstadoHistorial` json        NULL,
  PRIMARY KEY (`idCatalogoReplicacionEstado`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci;

INSERT INTO `rsv_catalogoreplicacionestado` (`idCatalogoReplicacionEstado`) VALUES (1)
ON DUPLICATE KEY UPDATE `idCatalogoReplicacionEstado` = `idCatalogoReplicacionEstado`;
```

Aplicado por script (`database/sql/`) y registrado en `migrations` a mano, siguiendo la costumbre
del proyecto: hay migraciones anteriores marcadas `Pending` que ya se aplicaron por SQL, y correr
`php artisan migrate` las volvería a ejecutar.

`catalogoReplicacionEstadoHistorial` **no estaba en el prompt.** Es la columna que resuelve D-8;
ver §4.4.

---

## 3. CONTRATO DE CANONICALIZACIÓN — obligatorio para la Fase 2 de CL034

Implementado en `app/Library/FSG/CanonicalizadorCatalogo.php`. **Ese archivo es la
especificación**; esto es su resumen legible.

### 3.1 Por qué no basta con `json_encode` de lo que salga de la base

| Tipo real en `asturias_api` | El problema | La regla |
|---|---|---|
| Importes `double(18,2)` | Binario flotante: `4500.10` no es `4500.10`. Un lado serializa `4500.1`, el otro `4500.1000000000004`. | **String de 2 decimales, siempre.** |
| Años tipo `year` | PDO los devuelve int o string según driver. *Verificado: aquí llegan string.* | `(int)` o `null`. |
| `char(1)` (`paqueteDiaInicio/Fin`, `tipoHabitacionOrden`) | Un lado guarda `null`, el otro `''`, para el mismo "sin valor". | `''` y solo-espacios → `null`. |
| Alias con acentos, `utf8mb4` | Sin `JSON_UNESCAPED_UNICODE` un lado escribe `Ñ` y el otro `Ñ`. | Banderas obligatorias (§3.3). |
| Fechas `date` | `'0000-00-00'` existe (sin STRICT) y significa "sin fecha". | 10 primeros chars; `'0000-00-00'` → `null`. |

### 3.2 Normalización por campo

| Tipo | Regla | Ejemplo |
|---|---|---|
| `entero` | `(int)`. Nunca null (columnas NOT NULL). | `"20"` → `20` |
| `enteroNulo` | `(int)`, o `null` si viene `null`/`''`. | `null` → `null` (no `0`) |
| `anio` | `(int)`, o `null` si `null`/`''`/`0`. | `"2026"` → `2026` |
| `importe` | `number_format((float)$v, 2, '.', '')` → **string**. | `1800.1` → `"1800.10"` |
| `fecha` | `substr(trim($v), 0, 10)`; `''` y `'0000-00-00'` → `null`. | `"2026-06-01 00:00:00"` → `"2026-06-01"` |
| `texto` | `trim((string)$v)`. El vacío **se conserva vacío**. | `"  Villa Ñ  "` → `"Villa Ñ"` |
| `opcional` | `trim`, y `''` → `null`. | `""` → `null` |

Asignación campo → tipo: constante `NORMALIZACION` del canonicalizador. `texto` para los ENUM y
alias; `opcional` para `tipoHabitacionOrden`, `paqueteDiaInicio`, `paqueteDiaFin`, `paqueteBloqueo`.

### 3.3 Algoritmo

1. Por catálogo, quedarse **solo** con las llaves de `LLAVES`, **en ese orden**, con el tipo de
   `NORMALIZACION`. *No `ksort`*: agregar un campo va al final de su lista, y el cambio de hash es
   deliberado y visible.
2. Ordenar las filas por su **llave natural** (`ORDEN`) ascendente, campo a campo en el orden
   declarado. Enteros por valor, textos por `strcmp`, `null` primero.
3. Construir el objeto `catalogos` con los cinco en el orden de `CATALOGOS`:
   `tipoHabitacion, paquete, extra, tipoHabitacionPrecio, tipoHabitacionPaquete`.
4. `json_encode($x, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)`.
5. `hash('sha256', $json)`.

El **hash por catálogo** (para el analizador) es lo mismo aplicado a la lista de un solo
catálogo, sin envolverla en el objeto del paso 3.

### 3.4 Qué NO entra en el hash

- `version`, `generadoEn`, `origen`, `ventana`, `versiones`. El hash identifica el **contenido**,
  no el envío. Con `generadoEn` dentro, cada regeneración cambiaría el hash y el corte por
  `sin_cambios` —que es el 99 % del tráfico— no cortaría nunca.
- Las PK sintéticas `idTipoHabitacionPrecio` e `idTipoHabitacionPaquete`. No viajan y no
  significan nada; las dos tablas se concilian por `uk_tipo_precio_anio` / `uk_tipo_paquete_anio`.
- `tipoHabitacionPrecioSocio` y `tipoHabitacionPrecioInvitado` de `rsv_tipohabitacion`.
  **Existen en el portal** (double(18,2), nullable) y **no las lee ni una línea** de `app/` ni de
  `resources/` — son residuo. Si CL034 las usa, se agregan a los dos lados en el mismo commit.

### 3.5 Vector de prueba dorado

Entrada deliberadamente fea (strings donde MySQL devuelve strings, `''`, `'0000-00-00'`, acento,
`/`, filas desordenadas). **La Fase 2 de CL034 debe reproducir estos hashes exactamente.**

```
tipoHabitacion
[{"idTipoHabitacion":1,"tipoHabitacionAlias":"Suite/Doble","tipoHabitacionCantidad":5,"tipoHabitacionHotel":"Hotel 2","tipoHabitacionOrden":"3","tipoHabitacionEstado":"Inactivo"},{"idTipoHabitacion":2,"tipoHabitacionAlias":"Villa Sencilla Ñ","tipoHabitacionCantidad":20,"tipoHabitacionHotel":"Hotel 1","tipoHabitacionOrden":null,"tipoHabitacionEstado":"Activo"}]
→ 1a33b60c22088b631190a0643616b37d25f4a364537790a2c1558d29dba552b8

paquete
[{"idPaquete":12,"paqueteAlias":"Verano 2026","paqueteFechaInicio":"2026-06-01","paqueteFechaFin":null,"paqueteDiaInicio":null,"paqueteDiaFin":null,"paqueteDiasMinimo":2,"paqueteDiasMaximo":null,"paqueteBloqueo":null,"paqueteEstado":"Activo"}]
→ 6ad9f9b374b597b391beeb50418f72577366309579df207e486f3d372926c7db

extra
[{"idExtra":1,"extraAlias":"Cama extra","extraPrecioSocio":"250.00","extraPrecioInvitado":"350.50","extraEstado":"Activo"}]
→ 320945d0abaaec81335ffe6ead87956cfdcbf874674e946da61352c90cc589f4

tipoHabitacionPrecio
[{"idTipoHabitacion":1,"tipoHabitacionPrecioTipo":"Invitado","tipoHabitacionPrecioAnioAplicacion":2026,"tipoHabitacionPrecioImporte":"2000.00"},{"idTipoHabitacion":1,"tipoHabitacionPrecioTipo":"Socio","tipoHabitacionPrecioAnioAplicacion":2026,"tipoHabitacionPrecioImporte":"1800.10"}]
→ 8b5bddd69aabec826cbe4660d163ffa938b3d4589ef0f5ace3f1c9c31d69cb41

tipoHabitacionPaquete
[{"idTipoHabitacion":1,"idPaquete":12,"tipoHabitacionPaqueteAnioAplicacion":2026,"tipoHabitacionPaqueteImporte":"4500.00"}]
→ 6490d39a0e9657b0bb10bd7ded60afa0cccbb9773429a8050667b127afe4729e

HASH GLOBAL de los cinco juntos
→ f39ba5a08e5dab96f81e58216e585941eaac5082a446fa232317d230a250b5ed
```

Fíjate en lo que demuestra cada uno: `"Ñ"` sin escapar, `"Suite/Doble"` con la barra sin escapar,
`""` → `null` en `tipoHabitacionOrden`, `'0000-00-00'` → `null` en `paqueteFechaFin`, `1800.1` →
`"1800.10"`, año string → int, y `Invitado` antes que `Socio` por orden de llave natural.

### 3.5.1 Contrato v2 (2026-10-07): precios de extras por año

Cambio de contrato coordinado con CL034 (`CL034RESERVACIONESV2/docs/PROPUESTA_PRECIOS_EXTRAS_POR_ANIO.md`
§7). `version` del sobre: **2** (`AplicarCatalogoFormRequest` acepta solo `in:2`).

| Pieza | v1 | v2 |
|---|---|---|
| `CATALOGOS` | 5 | 6: `extraPrecio` **al final** |
| `LLAVES['extra']` | `idExtra, extraAlias, extraPrecioSocio, extraPrecioInvitado, extraEstado` | `idExtra, extraAlias, extraEstado` |
| `LLAVES['extraPrecio']` | — | `idExtra, extraPrecioTipo, extraPrecioAnioAplicacion, extraPrecioImporte` (sin PK sintética) |
| `ORDEN['extraPrecio']` | — | `idExtra, extraPrecioTipo, extraPrecioAnioAplicacion` (uk_extra_precio_anio) |
| Ventana | precios con año ≥ `anioDesde` | igual, también para `extraPrecio` (`AplicarCatalogoPrivadoService::PRECIOS`) |
| Contador de versión | `tipohabitacion`, `paquete`, `extra` | sin contador nuevo: `rsv_extraprecio` marca `extra` en CL034 |

DDL: `database/sql/rsv_extraprecio.sql` (+ migración `2026_10_07_120000_create_rsv_extraprecio_table.php`),
que además deja `rsv_extras.extraPrecio*` NULL-ables (ya no viajan; un extra NUEVO no podría
insertarse con NOT NULL). **Se despliega ANTES que el emisor v2 de CL034.**

Vector dorado v2 (calculado con los DOS canonicalizadores, idénticos byte a byte desde la
declaración de la clase). Los cuatro catálogos que no cambiaron conservan su hash v1:

```
tipoHabitacion        → 1a33b60c22088b631190a0643616b37d25f4a364537790a2c1558d29dba552b8  (igual que v1)
paquete               → 6ad9f9b374b597b391beeb50418f72577366309579df207e486f3d372926c7db  (igual que v1)
tipoHabitacionPrecio  → 8b5bddd69aabec826cbe4660d163ffa938b3d4589ef0f5ace3f1c9c31d69cb41  (igual que v1)
tipoHabitacionPaquete → 6490d39a0e9657b0bb10bd7ded60afa0cccbb9773429a8050667b127afe4729e  (igual que v1)

extra
[{"idExtra":1,"extraAlias":"Cama extra","extraEstado":"Activo"}]
→ 102d36cf5b1cf40117e0ce0f263098447cae1b8bed8387e23521aaab246b7144

extraPrecio
[{"idExtra":1,"extraPrecioTipo":"Invitado","extraPrecioAnioAplicacion":2026,"extraPrecioImporte":"580.00"},{"idExtra":1,"extraPrecioTipo":"Socio","extraPrecioAnioAplicacion":2026,"extraPrecioImporte":"290.00"},{"idExtra":1,"extraPrecioTipo":"Socio","extraPrecioAnioAplicacion":2027,"extraPrecioImporte":"310.00"}]
→ f47e2249701cccd2e3c5c34421e1d02ce7b0a33faa7145163386ffabfdcf8261

GLOBAL v2 → e114c39319559e3f5682f7e580a75ac701df131d936a9db4276e85599d84acb9
```

Verificado además de punta a punta en local (CL034 `replicacion:catalogos --enviar --forzar`):
el portal aplicó el sexto catálogo y `extra` / `extraPrecio` empatan en `--verificar`.

### 3.6 El portal RECALCULA el hash y rechaza si no cuadra

`aplicar` recalcula el hash del bloque `catalogos` y lo compara con el `hash` declarado. Si
difieren: **422 `hash_no_coincide`**, con los dos hashes en la respuesta.

No es paranoia. La firma HMAC ya garantiza que el cuerpo llegó íntegro, así que una discrepancia
solo puede significar que las dos canonicalizaciones divergieron. Y eso **hay que descubrirlo en
la Fase 2**, no en la Fase 5: si los hashes se calculan distinto, `estado.hashesPorCatalogo` —que
el portal computa sobre sus filas reales— jamás va a empatar con el de CL034, y el analizador
reportará divergencia para siempre sobre datos idénticos.

---

## 4. Las cuatro decisiones de diseño que se resolvieron distinto al prompt

### 4.1 D-2 · El correo va por `REPLICACION_MONITOREO_CORREOS`

El prompt decía reutilizar `SINCRONIZACION_MONITOREO_CORREOS`. **Esa llave no la lee
`MonitorReservaciones`**: la clase consulta `config('replicacion.monitoreo.correos')`, que viene
de `REPLICACION_MONITOREO_CORREOS`. Seguir el prompt al pie de la letra habría mandado las alertas
al vacío. Se usa la llave correcta, que ya existe y ya está poblada. Sin llave nueva.

### 4.2 D-4 · Convivencia con la venta en vivo

**El problema, que el prompt no contemplaba.** `ReservacionService::agregarHabitacion()` toma
`lockForUpdate()` sobre `rsv_tipohabitacion` dentro de la transacción del socio, como mutex de
cupo. `aplicar` escribe esa misma tabla. Dos transacciones tocando las mismas filas en orden
distinto es la definición de un interbloqueo, y las consecuencias son asimétricas: al socio se le
cae la petición.

Y el modo de falla es peor de lo que parece: si `aplicar` devolviera **500** por un deadlock,
CL034 lo clasificaría como transitorio, reintentaría cinco veces y **abriría el cortacircuitos —
que es el mismo que usa el canal de reservaciones**. Una contención de un segundo frenaría la
réplica de las ventas.

**Tres medidas:**

1. **Se escribe solo lo que cambió.** Se lee el catálogo (11 + 31 + 2 filas), se compara
   canónicamente y se hace `upsert` únicamente de las filas distintas. En régimen normal —cambió
   un precio, no una cabecera— `aplicar` **no toma ni un lock** sobre `rsv_tipohabitacion`. Lo
   mismo con los precios: si el conjunto en ventana es idéntico, no se borra ni se inserta nada.
2. **Orden de bloqueo fijo.** Todo `aplicar` empieza tomando la fila 1 del estado con
   `lockForUpdate`, así que dos réplicas se serializan ahí y no pueden interbloquearse entre
   ellas. Luego se recorren las tablas siempre en el mismo orden.
3. **Se espera poco y se falla bien.** `innodb_lock_wait_timeout` baja a 5 s (configurable) y se
   **restaura en `finally`** — un ajuste de sesión que se escapa a otras peticiones del mismo
   worker es un bug a plazo. Los errores 1205 y 1213 se traducen a **409 `contencion`**, que
   CL034 reintenta con backoff.

**Efecto lateral valioso:** la primera réplica sobre una base ya alineada es un no-op verificable.
No se regeneran las PK sintéticas de las tablas de precio, así que el `mysqldump` posterior es
**byte a byte idéntico** al anterior. Ver prueba 3.

### 4.3 D-7 · La canonicalización está congelada en código, no "por acordar"

Ver §3. El prompt la dejaba para acordar con la Fase 2; con `double(18,2)` de por medio eso no se
puede posponer. Está fijada, probada con un vector dorado y autoverificada en cada envío (§3.6).

### 4.4 D-8 · La transferencia tiene que poder funcionar SIEMPRE

**El problema.** El corte por versión especificado —"si alguna versión recibida es menor que la
aplicada, ignorar"— wedgea el canal **de forma permanente** si algún día se trunca, resiembra o
restaura la tabla de marca de CL034: sus contadores vuelven a empezar y todo snapshot posterior
queda rechazado sin recurso. Un canal que puede bloquearse para siempre por una operación de
mantenimiento no es confiable.

**La solución: distinguir los dos casos, que hasta ahora se veían iguales.** Se guardan los
últimos N hashes aplicados (`catalogoReplicacionEstadoHistorial`, N=10 por config):

| Situación | Qué es | Qué hace el portal |
|---|---|---|
| Versión menor + hash **que ya aplicó** | Reintento tardío. Es el caso que E6 describe. | `200 aplicado:false, motivo:'version_anterior'`. Cero escrituras. |
| Versión menor + hash **que nunca vio** | Reinicio de contadores del emisor. No puede ser eco de nada aplicado. | **Se aplica** + incidencia `R18`. |
| `forzar: true` en el cuerpo (firmado) | Decisión explícita del operador. | Salta el corte por versión. **No salta** ventana, ENUM, integridad ni hash. |

El peor caso del segundo renglón es aplicar brevemente un estado que ya no es el último; el
siguiente envío —segundos después, con versión mayor— reconverge. **Converger tarde es
recuperable; quedarse bloqueado no.**

> ⚠ **Para CL034:** con `motivo: 'version_anterior'` **no se debe avanzar el puntero
> `VersionEnviada`**. No se aplicó nada. Tratarlo como éxito dejaría al emisor creyéndose
> sincronizado cuando no lo está.

### 4.5 Bonus · Los ENUM se validan en el servicio, no en el Form Request

La primera versión llevaba `Rule::in(ENUMS[...])` en el Form Request. Funcionaba y estaba mal: el
rechazo salía con `motivo: 'contrato'` genérico y **sin levantar R18**. Un ENUM desconocido es
justo el síntoma de que la traducción del emisor se rompió — de las poquísimas cosas de este canal
que merecen un correo. Ahora la forma es del Form Request y los valores admitidos son del
servicio, que rechaza con el valor ofensor en la respuesta y en la bitácora.

---

## 5. Orden de la aplicación

**Fuera de la transacción** (no leen la base, así que no hay nada que hacer atómico — y así un
snapshot defectuoso nunca llega a rozar `rsv_tipohabitacion`):

| # | Validación | Rechazo |
|---|---|---|
| 1 | `ventana.anioDesde >= YEAR(CURDATE())` | 422 `ventana_invalida` + R18 |
| 2 | Lista blanca de ENUM | 422 `enum_desconocido` + R18 |
| 3 | Llaves naturales sin duplicar dentro del snapshot | 422 `llave_duplicada` + R18 |
| 4 | Todo id citado por un precio existe en las cabeceras del **mismo** snapshot | 422 `integridad_snapshot` + R18 |
| 5 | Toda fila de precio cae dentro de la ventana declarada | 422 `fuera_de_ventana` + R18 |
| 6 | Hash recalculado == hash declarado | 422 `hash_no_coincide` + R18 |

**Dentro de UNA transacción:**

7. Candado `lockForUpdate` sobre la fila 1 del estado.
8. Corte por hash → `200 sin_cambios`, cero escrituras.
9. Corte por versión → §4.4.
10. Diff de cabeceras y `upsert` **con el ID explícito** de solo lo que cambió, en orden fijo:
    `rsv_tipohabitacion` → `rsv_paquete` → `rsv_extras`.
11. Apagado de las cabeceras ausentes (`Inactivo` / `Eliminado`). Se filtran las que ya estaban
    apagadas, para no tomar locks por nada.
12. Precios: si el conjunto en ventana cambió, `DELETE ... WHERE anio IS NOT NULL AND anio >= :anioDesde`
    + `INSERT` en lotes de 500. Si no cambió, **no se toca la tabla**.
13. Sello: hash, versiones, fecha, `anioDesde`, resumen, historial.

**Las tres validaciones 3, 5 y 6 no estaban en el prompt.** Las tres evitan que un defecto salga
como error de MySQL (1062 duplicate key, 1452 FK) → 500 → cortacircuitos abierto por un problema
de datos.

### 5.1 Por qué NUNCA un `DELETE` sobre las cabeceras

El prompt argumentaba "hay FK `RESTRICT`". **Es medio cierto, y el riesgo real es mayor.** FKs
verificadas en `asturias_api`:

```
rsv_tipohabitacionpaquete.idPaquete        → rsv_paquete
rsv_tipohabitacionpaquete.idTipoHabitacion → rsv_tipohabitacion
rsv_tipohabitacionprecio.idTipoHabitacion  → rsv_tipohabitacion
```

**No hay ninguna FK** desde `rsv_reservacionhabitacion.idTipoHabitacion`,
`rsv_reservacionhabitacionpaquete.idPaquete` ni `rsv_reservacionhabitacionextra.idExtra`. Es
decir: un `DELETE` sobre `rsv_extras` **no fallaría** — pasaría limpio y dejaría ventas apuntando a
un extra inexistente, sin un solo error. La prohibición es más importante de lo que la propuesta
suponía, no menos.

### 5.2 El servidor corre con `sql_mode` VACÍO

`SELECT @@sql_mode` devuelve cadena vacía. Sin STRICT, MySQL no rechaza un dato fuera de rango:
**convierte en silencio**. Un ENUM inválido se guarda como `''`; un alias de 60 caracteres se
trunca a 50; un `char(1)` de dos letras pierde la segunda. **La base no es la última línea de
defensa: no hay ninguna después del Form Request y de la lista blanca del servicio.** Por eso cada
`max:` del Form Request es el ancho real de la columna, no una cifra razonable.

---

## 6. Códigos de respuesta

| HTTP | `motivo` | Significado para CL034 |
|---|---|---|
| 200 | `null` | Aplicado. |
| 200 | `sin_cambios` | Ya tenía ese hash. **Caso normal y frecuente**, no un error. |
| 200 | `version_anterior` | Reintento tardío, ignorado. **No avanzar `VersionEnviada`.** |
| 409 | `contencion` | Base ocupada. **Transitorio**: reintentar con backoff. |
| 422 | `contrato` | Forma del payload inválida. `errors` trae el detalle completo. |
| 422 | `ventana_invalida`, `enum_desconocido`, `llave_duplicada`, `integridad_snapshot`, `fuera_de_ventana`, `hash_no_coincide` | **Permanente.** Defecto del emisor; reintentar no lo arregla. Cero escrituras. |
| 401 / 403 | — | Firma inválida / IP fuera de la allowlist. En JSON. |
| 500 | `interno` | Solo ante un fallo inesperado del portal. |

El `contexto` viaja **en la respuesta**, no solo al log: del otro lado hay un proceso automático y
una pantalla de operador, no alguien con acceso a este servidor.

### 6.1 Respuesta de `GET /estado`

```json
{
  "hash": "…", "hashVivo": "…",
  "versiones": {"tipohabitacion": 0, "paquete": 0, "extra": 0},
  "aplicadoEn": "2026-08-25T12:46:58-06:00",
  "ventana": {"anioDesde": 2026},
  "conteos": {"tipoHabitacion": 11, "paquete": 31, "extra": 2,
              "tipoHabitacionPrecio": 22, "tipoHabitacionPaquete": 187},
  "fueraDeVentana": {"tipoHabitacionPrecio": 0, "tipoHabitacionPaquete": 251},
  "hashesPorCatalogo": {"…": "…"},
  "ultimoResumen": {"insertados": 0, "actualizados": 0, "apagados": 0, "borrados": 0}
}
```

Dos añadidos sobre el prompt, y los dos importan:

- **`hashVivo`** — el hash de lo que hay **ahora en la base**, frente a `hash`, que es el
  **sellado**. En régimen normal son el mismo. Que difieran es la firma exacta de que alguien
  escribió el catálogo por fuera del canal (D7/E12), y tenerlo ahorra al analizador una ronda de
  diff detallado.
- **`fueraDeVentana`** — sin esto, CL034 no puede distinguir "el portal tiene 187 precios en
  ventana, correcto" de "el portal perdió las 251 filas de legado": los dos casos le llegan como
  187.

Los conteos y `hashesPorCatalogo` se calculan **sobre las filas reales de la base**. Leerlos del
sello sería más barato y completamente inútil: el endpoint solo repetiría lo que el portal *cree*.

`estado` **no acepta parámetros a propósito**: la firma HMAC cubre `getPathInfo()` y **no** el
query string, así que cualquier filtro que viajara por ahí llegaría sin firmar.

---

## 7. Resultado de la verificación

Ejecutada el 2026-08-25 contra `asturias_api` local, con `php artisan serve` en `127.0.0.1:8899` y
un emisor de prueba que firma como lo hará CL034. Se tomó `mysqldump` de las cinco tablas antes de
empezar y se comparó después de cada bloque.

| # | Prueba | Resultado |
|---|---|---|
| 1 | `POST aplicar` con la llave en `false` | ✅ **404 en JSON** (no 500, no 403, no HTML) |
| 2 | `route:list \| grep catalogo` con la llave en `false` | ✅ Ninguna ruta del canal |
| 3 | Snapshot idéntico al estado actual | ✅ `aplicado:true` con **todos los contadores en 0**, precios incluidos. `mysqldump` posterior **idéntico byte a byte**, PK sintéticas incluidas |
| 4 | Reenviar el mismo snapshot | ✅ `200 aplicado:false, motivo:'sin_cambios'`, cero escrituras |
| 5 | Corte por versión (secuencia A→B→A) | ✅ `200 version_anterior`, cero escrituras |
| 5b | **D-8:** versiones menores con hash nunca visto | ✅ **Se aplica** + `R18` con firma `…\|version_reinicio` |
| 6 | `ventana.anioDesde: 2020` | ✅ **422 `ventana_invalida`**. Precios de años anteriores y las **251 filas `NULL` intactas** |
| 7 | `tipoHabitacionHotel: "V"` | ✅ **422 `enum_desconocido`** con el valor ofensor, los admitidos y la pista. **Cero escrituras**: ni las cabeceras válidas se aplicaron |
| 8 | Falta un tipo de habitación | ✅ Queda `Inactivo`, **sigue existiendo**, sus reservaciones intactas, las 251 `NULL` intactas |
| 9 | Precio que cita `idPaquete: 999999` | ✅ **422 `integridad_snapshot`**, sin depender del error de FK |
| 10 | Dos `aplicar` concurrentes | ✅ Uno aplica, el otro se serializa tras el candado y sale por `sin_cambios` |
| 11 | Firma inválida | ✅ **401 en JSON** |
| 12 | `GET estado` | ✅ Conteos coinciden con `SELECT COUNT(*)` manual: 11/31/2/22/187 + 251 fuera de ventana |

**Pruebas añadidas que el prompt no pedía:**

| # | Prueba | Resultado |
|---|---|---|
| 13 | **D-4:** `aplicar` que debe escribir una cabecera, contra un `lockForUpdate` abierto sobre `rsv_tipohabitacion` (el mutex de la venta) | ✅ **409 `contencion`** a los ~5 s con `errno 1205`, **no** 500 ni espera de 50 s. Catálogos idénticos al respaldo: la transacción revirtió entera |
| 14 | Hash declarado que no cuadra con el canónico | ✅ **422 `hash_no_coincide`** con los dos hashes en la respuesta |
| 15 | Falta la llave `catalogos.extra` | ✅ **422 `contrato`** — la ausencia no se lee como vacío |
| 16 | `forzar: true` con versiones menores | ✅ Se aplica, saltando solo el corte por versión |

**Estado final del entorno:** los catálogos quedaron **byte a byte idénticos** al respaldo previo
(11 / 31 / 2 / 22 / 438 filas, 251 con año `NULL`), `rsv_catalogoreplicacionestado` con la fila 1
vacía, y `REPLICACION_CATALOGOS_HABILITADA=false` en `.env` y en `.env.example`.

---

## 8. Prohibiciones que este código sostiene

1. **No se crean catálogos por ningún otro camino.** Verificado sobre todo `app/`: no hay un solo
   `create/insert/update/upsert` a los cinco modelos fuera de este canal. Sin seeders ni factories
   apuntando a la base real.
2. **Ningún `DELETE` sobre `rsv_tipohabitacion`, `rsv_paquete` ni `rsv_extras`.** Ver §5.1.
3. **No se toca ninguna fila de precio con año `NULL` ni anterior a la ventana.** El filtro va en
   el `WHERE` del SQL, con `IS NOT NULL` explícito y redundante a propósito.
4. **No se traduce ningún ENUM.** Llegan legibles y se escriben legibles.
5. **No se inventó ni un campo.** Las cinco tablas quedaron exactamente como estaban.
6. **No se tocó** `routes/modulos/reservaciones/replicacion.php` ni nada del canal de
   reservaciones.
7. **El interruptor quedó en `false`.**

---

## 9. Qué falta y qué sigue

**Pendiente de confirmar con CL034 (Fase 2):**

- Si CL034 tiene y usa `tipoHabitacionPrecioSocio` / `tipoHabitacionPrecioInvitado` en
  `rsv_tipohabitacion`. Aquí están muertas y **no viajan** en el contrato; si allá tienen datos, se
  pierden en silencio.
- Que su canonicalización reproduzca los hashes de §3.5 **antes** de encender nada.
- Que trate `motivo: 'version_anterior'` sin avanzar `VersionEnviada` (§4.4).

**Auditoría de producción (Fase 0 del plan).** Todo lo verificado aquí es LOCAL. La identidad de
IDs y las 251 filas `NULL` deben re-auditarse contra producción antes de encender.

**Siguiente paso:** Fase 2 en CL034 (tabla de marca, `SnapshotCatalogosPortal`,
`replicacion:catalogos --verificar`). Este portal ya puede recibir; solo hay que encenderlo en la
Fase 4 poniendo `REPLICACION_CATALOGOS_HABILITADA=true` y limpiando cachés
(`php artisan optimize:clear` — con `route:cache` activo, cambiar el `.env` no registra las rutas).
