# CONFIGURACIÓN — Documento de identificación del invitado · lado PORTAL (`CL032BACK`)

> **Para quien despliega y no leyó el código.**
> Cubre la fase 11 del canal de invitados en el **Portal Público**: qué configurar, dónde viven
> físicamente las identificaciones que sube el socio, cómo vigilar que el disco no se llene y
> cómo verificar que Tlalpan puede descargarlas.
>
> **Sin valores reales.** Todo lo que va entre `<<...>>` es un marcador que se sustituye en el
> despliegue. Los secretos y las IP de producción **no se escriben en este archivo ni se envían
> por correo o chat**.
>
> Documentos hermanos:
> - Diseño: [`PROPUESTA_DOCUMENTO_IDENTIFICACION_INVITADOS.md`](PROPUESTA_DOCUMENTO_IDENTIFICACION_INVITADOS.md)
> - Canal completo: [`../PROPUESTA_TRANSFERENCIA_SOLICITUDES_INVITADOS.md`](../PROPUESTA_TRANSFERENCIA_SOLICITUDES_INVITADOS.md)
> - Contrato con el otro lado: [`NOTA_COORDINACION_DOCUMENTOS_TLALPAN.md`](NOTA_COORDINACION_DOCUMENTOS_TLALPAN.md)

---

## 0. Qué se entregó y qué NO

| Fase | Entregable | Estado |
|---|---|:--:|
| 1 | `documentoBytes` y `documentoSha256` en el lote de `/pendientes` | ✅ Entregado |
| 2 | Sonda `GET /invitados/transferencia/salud` | ✅ Entregado |
| 3 | Comando de purga `invitados:purgar-documentos` | 🔒 **NO entregado** |

**La purga NO está implementada** porque la decisión **D6** (¿el Portal borra los documentos a los
30 días de la fecha de salida?) sigue **pendiente de autorización**
([`PROPUESTA_DOCUMENTO_IDENTIFICACION_INVITADOS.md` §7](PROPUESTA_DOCUMENTO_IDENTIFICACION_INVITADOS.md),
[`../PROPUESTA_TRANSFERENCIA_SOLICITUDES_INVITADOS.md` §8, D6](../PROPUESTA_TRANSFERENCIA_SOLICITUDES_INVITADOS.md)).

**Consecuencia operativa que hay que asumir hoy:** mientras D6 no se resuelva, **el almacenamiento
crece sin límite**. Nada borra las identificaciones. Por eso la sección §5 (vigilancia del
crecimiento) no es opcional: es el sustituto temporal de la purga. La §7 describe el
procedimiento que aplicará **cuando** se autorice, para que no haya que redactarlo con prisa.

---

## 1. El contrato en una pantalla

El Portal **nunca** inicia una conexión hacia Tlalpan (regla **R1** de
[`../ARQUITECTURA_SERVIDORES.md`](../ARQUITECTURA_SERVIDORES.md) §4). Solo expone endpoints
firmados y responde:

```
GET  /invitados/transferencia/pendientes             lote de solicitudes + metadato del documento
GET  /invitados/transferencia/documento/{idInvitado}  el archivo, binario en crudo
GET  /invitados/transferencia/salud                   sonda del circuit breaker (200, cuerpo vacío)
POST /invitados/transferencia/recibidas               acuse
POST /invitados/transferencia/estado                  veredicto
```

Por cada invitado, el lote de `/pendientes` lleva **tres campos** del documento:

```jsonc
"documento":       "/invitados/transferencia/documento/31",   // referencia, NO el archivo
"documentoBytes":  1204873,                                    // para rechazar antes de descargar
"documentoSha256": "ac6be936…4ce0"                             // integridad + detección de reemplazo
```

**Los tres van en `null` a la vez cuando el invitado no subió foto.** La única excepción es
`documentoSha256`, que puede venir en `null` con `documentoBytes` presente si el hash se apagó a
propósito (§3). Tlalpan debe tolerar ese caso; no es un error.

El archivo **jamás viaja dentro del lote**. Va por su propio `GET`, binario en crudo, y solo
cuando Tlalpan lo pide.

---

## 2. Dónde viven físicamente los archivos

| | |
|---|---|
| Disco Laravel | `local` — **privado**, definido en `config/filesystems.php` |
| Raíz física | `<<RUTA_PROYECTO>>/storage/app/private/` |
| Ruta del archivo | `solicitudinvitado/{idSolicitud}/{idInvitado}.{ext}` |
| Ejemplo completo | `<<RUTA_PROYECTO>>/storage/app/private/solicitudinvitado/12/31.jpg` |
| Extensiones posibles | `jpg`, `jpeg`, `png`, `webp`, **`pdf`** (validado al subir, máx. **12 MB**) |
| ¿Accesible por URL? | **No.** Solo por el endpoint firmado |

> ⚠️ **`local` ≠ `storage/app`.** En Laravel 11 la raíz del disco `local` es
> `storage/app/**private**`. Si busca los archivos en `storage/app/solicitudinvitado` no los va a
> encontrar y va a concluir que la subida falló.

**Nunca mover este disco a `public`.** Son identificaciones oficiales de terceros; en `public`
quedarían accesibles por URL directa, sin firma y sin allowlist.

### 2.1 Permisos

Los archivos los escribe **PHP-FPM** (el usuario del servidor web) y los lee el mismo proceso al
servir el endpoint. Nadie más necesita tocarlos.

```bash
# Propietario del árbol de storage: el usuario del servidor web (<<USUARIO_WEB>>, típico: www-data)
sudo chown -R <<USUARIO_WEB>>:<<USUARIO_WEB>> <<RUTA_PROYECTO>>/storage

# Directorios 750, archivos 640: ni el grupo "otros" del sistema los lee
sudo find <<RUTA_PROYECTO>>/storage/app/private -type d -exec chmod 750 {} \;
sudo find <<RUTA_PROYECTO>>/storage/app/private -type f -exec chmod 640 {} \;
```

**Verificación:**

```bash
# 1) El árbol existe y es del usuario web
ls -ld <<RUTA_PROYECTO>>/storage/app/private/solicitudinvitado

# 2) NO debe existir un enlace desde public/ hacia el disco privado.
#    `storage:link` solo debe enlazar storage/app/public. Si aparece cualquier cosa
#    apuntando a app/private, es una fuga de datos personales: quitarlo.
ls -la <<RUTA_PROYECTO>>/public/ | grep -i storage
readlink -f <<RUTA_PROYECTO>>/public/storage
```

### 2.2 Nota de endurecimiento (no bloquea el despliegue)

`config/filesystems.php` trae `'serve' => true` en el disco `local` (es el valor por omisión de
Laravel 11). Eso registra una ruta `GET /storage/{path}` que **exige una URL firmada por Laravel**
(`APP_KEY`) para entregar un archivo privado: una petición sin firma recibe 404 en producción, así
que **hoy no hay exposición**. Además, **ningún código del proyecto genera esas URL firmadas**
(verificado: no hay usos de `storage.local` ni de `temporarySignedRoute` en `app/`).

Aun así, es una segunda puerta hacia el mismo disco que nadie usa. Como endurecimiento —**fuera del
alcance de esta entrega, porque el disco `local` lo comparten otros módulos**— conviene evaluar
ponerlo en `false` y verificar que nada se rompa:

```bash
# Debe listar 2 rutas storage/{path}; si se pone serve=false, deben desaparecer
php artisan route:list --path=storage
```

---

## 3. Variables de `.env`

Las de este entregable son las **dos últimas**; el resto ya existía y se lista porque sin ellas el
canal completo no responde.

| Variable | ¿Obligatoria? | Valor | Qué pasa si falta |
|---|:--:|---|---|
| `INVITADOS_HMAC_SECRET` | **Sí** | `<<SECRETO_HMAC_INVITADOS>>` | **503** a todo. Nunca se firma contra cadena vacía |
| `INVITADOS_IPS_PERMITIDAS` | **Sí** | `<<IP_SALIENTE_TLALPAN>>` | **403** a todo. El canal nace cerrado por diseño |
| `INVITADOS_HMAC_SOURCE` | No | `tlalpan` | Informativo |
| `INVITADOS_VENTANA` | No | `300` | Debe ser **igual** a la de Tlalpan o salen 401 intermitentes |
| `INVITADOS_NONCE_TTL` | No | `600` | Debe ser **≥** la ventana |
| `INVITADOS_RATE_LIMIT` | No | `60,1` | 60 peticiones/min |
| **`INVITADOS_DOCUMENTO_HASH`** | No | `true` | En `false`, `documentoSha256` sale `null` (ver abajo) |
| **`INVITADOS_DOCUMENTO_HASH_TTL`** | No | `2592000` | Segundos que vive cada hash memoizado (30 días) |

### 3.1 Sobre `INVITADOS_DOCUMENTO_HASH`

**Déjelo en `true`.** Es lo que le permite a Tlalpan verificar la integridad de lo que descargó y,
sobre todo, **detectar que el socio reemplazó la foto** después de la transferencia.

**Costo medido** (PHP 8.4, disco local): `stat` ≈ 0.08 ms sin importar el tamaño; `hash_file`
≈ **4.9 ms por MB** → ≈ 59 ms en el tope de 12 MB. El hash se **memoiza por (ruta, fecha de
modificación, tamaño)**, así que se paga **una sola vez por versión del archivo** y no en cada uno
de los ~288 ciclos diarios de Tlalpan. Si el socio reemplaza la foto, la clave cambia sola y el
hash se recalcula: no hay nada que invalidar a mano.

Ponerlo en `false` es la **salida de emergencia** si en producción resultara caro (lotes muy
grandes con fotos en el tope). El lote sigue saliendo con `documentoBytes`.

> ⚠️ Apagarlo **degrada una garantía del contrato**. Se avisa a Tlalpan antes
> ([`NOTA_COORDINACION_DOCUMENTOS_TLALPAN.md`](NOTA_COORDINACION_DOCUMENTOS_TLALPAN.md)).

El memo vive en la caché de la aplicación (`CACHE_STORE`, hoy `database`). No requiere
configuración adicional. Un `php artisan cache:clear` solo obliga a recalcular; no rompe nada.

### 3.2 Aplicar los cambios

```bash
cd <<RUTA_PROYECTO>>
php artisan config:clear
php artisan config:cache      # solo si el despliegue usa configuración cacheada
php artisan route:clear
php artisan route:cache       # idem
```

> Con `config:cache` activo, **editar `.env` no surte efecto** hasta volver a cachear. Es la causa
> número uno de "ya puse el secreto y sigue dando 503".

---

## 4. Verificación del despliegue

Las tres pruebas se corren **desde Tlalpan** (o desde una máquina cuya IP saliente esté en
`INVITADOS_IPS_PERMITIDAS`). Desde cualquier otro lado la respuesta correcta es **403**, y eso
también es una prueba válida.

**Receta de firma** (convención del canal: **GET → se firma el path**):

```bash
# --- copiar/pegar, sustituyendo los marcadores ---
SECRET='<<SECRETO_HMAC_INVITADOS>>'
BASE='https://<<DOMINIO_PORTAL>>'
RUTA='/invitados/transferencia/salud'

TS=$(date +%s)
NONCE=$(openssl rand -hex 16)
SIG="sha256=$(printf '%s' "$TS.$NONCE.$RUTA" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

curl -s -o /dev/null -w 'HTTP %{http_code}\n' \
  -H "X-Signature: $SIG" -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" -H "X-Source: tlalpan" \
  "$BASE$RUTA"
```

| Prueba | Cómo | Esperado |
|---|---|---|
| **1. Sonda viva** | La receta de arriba tal cual | `HTTP 200`, cuerpo vacío |
| **2. Sin firma** | `curl -s -o /dev/null -w '%{http_code}\n' "$BASE/invitados/transferencia/salud"` | `401` (o `403` si la IP no está en la allowlist) |
| **3. Lote con metadato** | Repetir la receta con `RUTA='/invitados/transferencia/pendientes'` y sin `-o /dev/null` | JSON con `documento`, `documentoBytes` y `documentoSha256` por invitado |

**Prueba 4 — el metadato corresponde al archivo real.** Tomar un `idInvitado` del lote y
contrastar:

```bash
RUTA='/invitados/transferencia/documento/<<ID_INVITADO>>'
TS=$(date +%s); NONCE=$(openssl rand -hex 16)
SIG="sha256=$(printf '%s' "$TS.$NONCE.$RUTA" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')"

curl -s -H "X-Signature: $SIG" -H "X-Timestamp: $TS" -H "X-Nonce: $NONCE" \
  "$BASE$RUTA" -o /tmp/doc.bin

stat -c%s /tmp/doc.bin      # debe ser IGUAL a documentoBytes
sha256sum /tmp/doc.bin      # debe ser IGUAL a documentoSha256
rm -f /tmp/doc.bin
```

**Prueba 5 — el 404 es uniforme.** Con un `idInvitado` inexistente, con uno que existe pero no
subió foto, y con uno de otra solicitud, la respuesta debe ser **exactamente la misma**:
`404 {"success":false,"code":404,"message":"Documento no disponible."}`. Que no se distingan es
deliberado: distinguirlos convertiría el endpoint en un oráculo para enumerar ids.

> **Cada petición necesita su propio `X-Nonce`.** Repetir uno da **409 Petición duplicada** — eso
> es el anti-replay funcionando, no un fallo. Y `X-Timestamp` debe estar dentro de ±300 s del reloj
> del Portal: si los relojes derivan, el síntoma es un **401 mudo**. Verifique NTP en ambos lados.

---

## 5. Crecimiento del almacenamiento — cómo medirlo y vigilarlo

> **ACTUALIZADO EL 20-ago-2026 — YA HAY PURGA.** La frase que estuvo aquí ("mientras D6 no se
> autorice, nada borra estos archivos") **dejó de ser cierta**. El negocio autorizó la purga
> completa del histórico del Portal y está implementada: `php artisan portal:purgar-historico`,
> diaria a las 00:05. **Cada solicitud desaparece —fila, invitados y documentos— al día
> siguiente de su fecha de salida.** Ver §7 y `PROPUESTA_PURGA_HISTORICO_PORTAL.md`.
>
> Lo que cambia para esta sección: el crecimiento **ya no es acumulativo**. El disco tiende a un
> estado estacionario cuyo tamaño es "lo capturado durante la ventana de estancias vigentes", no
> "todo lo capturado desde que el portal existe". Las mediciones de abajo siguen valiendo —y
> siguen haciendo falta para dimensionar el pico—, pero la proyección "fecha en que el volumen se
> llena" ya no aplica salvo que la purga se apague (`PURGA_ACTIVA=false`).

**Cota superior:** 12 MB por invitado. Una solicitud de 5 invitados puede pesar **60 MB**. En la
práctica una foto de identificación ronda 1–3 MB, pero el dimensionamiento se hace con el tope,
no con el promedio.

> La cota bajaría a ~500 KB por invitado (**~2.5 MB por solicitud de 5**) en cuanto el Portal
> re-codifique la imagen al guardar. Es la medida que más impacto tiene sobre esta sección y sobre
> el tope efectivo que viaja a Tlalpan. Pendiente de implementar.

```
consumo_mensual ≈ solicitudes_por_mes × invitados_por_solicitud × tamaño_promedio
```

### 5.1 Medición

```bash
DOCS=<<RUTA_PROYECTO>>/storage/app/private/solicitudinvitado

# Total ocupado
du -sh "$DOCS"

# Cuántos archivos hay
find "$DOCS" -type f | wc -l

# Total, promedio y máximo en un solo renglón
find "$DOCS" -type f -printf '%s\n' | awk '{s+=$1; n++; if($1>m) m=$1}
  END {printf "%d archivos · %.1f MB total · %.2f MB promedio · %.2f MB el mayor\n",
       n, s/1048576, (n?s/n/1048576:0), m/1048576}'

# Las 20 solicitudes que más pesan
du -sm "$DOCS"/* 2>/dev/null | sort -n | tail -20

# Espacio libre del volumen (esto es lo que de verdad importa)
df -h "$DOCS"
```

### 5.2 Vigilancia

Sin purga, lo que hay que vigilar es **el disco del servidor**, no la carpeta. Un servidor sin
espacio no solo deja de recibir fotos: deja de escribir logs, caché y sesiones, y el portal cae
entero.

```bash
# Umbral simple para el cron del servidor (o para el monitor que ya exista).
# Avisa cuando el volumen pase del 80 % de uso.
USO=$(df --output=pcent "<<RUTA_PROYECTO>>/storage" | tail -1 | tr -dc '0-9')
[ "$USO" -ge 80 ] && echo "ALERTA: storage al ${USO}% en <<DOMINIO_PORTAL>>"
```

**Cadencia sugerida:** medición mensual anotada (§5.1) + alerta automática de disco al 80 %.
Con dos o tres mediciones mensuales ya se puede proyectar la fecha en que el volumen se llena, que
es exactamente el argumento que necesita la decisión D6.

---

## 6. Cambios de esquema

**Ninguno.** No hay script SQL en este entregable, y es una conclusión, no un olvido:

- `documentoBytes` y `documentoSha256` **se derivan del sistema de archivos** en el momento de
  armar el lote (`stat` + hash memoizado). Guardarlos en columnas introduciría un segundo lugar
  donde vive la verdad, que habría que mantener sincronizado con el disco: si el socio reemplaza
  la foto y la columna no se actualiza, Tlalpan rechazaría por integridad una descarga correcta.
  El disco ya es la fuente de verdad; el metadato se calcula sobre él.
- La purga (cuando se autorice) usa **columnas que ya existen**: `solicitudTransferencia` y
  `solicitudFechaSalida` en `inv_solicitud`. Borra **archivos**, no registros.
- El único cambio de esquema de la fase 11 es **del lado de Tlalpan**
  (`solicitudInvitadoDocumentoLocal`, `solicitudInvitadoDocumentoIntentos`) y viene en su propio
  entregable, no en éste.

---

## 7. Purga de documentos — ✅ IMPLEMENTADA (20-ago-2026)

> **AUTORIZADA Y EN CÓDIGO.** Esta sección describía un plan pendiente de la decisión D6. El
> negocio resolvió, y resolvió **más amplio** de lo que este documento anticipaba. Lo que sigue
> es lo que hay, no lo que se pensaba hacer.

**Qué se construyó:** `app/Console/Commands/PurgarHistoricoPortal.php`
(`php artisan portal:purgar-historico`), con `--dry-run`, `--limite=` y `--fecha=`.

**En qué se APARTÓ del plan original, y por qué.** Vale la pena leerlo antes de tocar nada:

| El plan de esta sección | Lo que se implementó | Motivo |
|---|---|---|
| Purgar **solo los documentos**, dejando la fila | Purga **fila, invitados, documentos, reservación y detalle** | El Portal es un punto de captura, no un archivo. El histórico ya vive en Tlalpan y en Reservaciones V2; conservar aquí la fila sin su documento no servía a nadie |
| Candado `solicitudTransferencia = 'Si'` | **Sin candado de estado** | Decisión explícita del negocio: se purga sin importar el estado. Ver el análisis del riesgo residual en `PROPUESTA_PURGA_HISTORICO_PORTAL.md` §5.1 |
| `--dias=30` de retención | **0 días**: `fechaSalida < hoy` | No es una preferencia: es el corte que YA aplican Tlalpan y Reservaciones V2. Cualquier otro desalinea al Portal de sus dos emisores. Configurable con `PURGA_DIAS_GRACIA` si el negocio pide margen |
| `dailyAt('03:15')` | `dailyAt('00:05')` | El negocio pidió "a las 12 de la noche". 00:05 y no 00:00 para no competir con el ping de disponibilidad de 10 s, que corre en primer plano |

> ⚠️ **El candado que se retiró tenía una razón, y sigue teniéndola.** Una solicitud con
> `solicitudTransferencia = 'No'` nunca llegó a Tlalpan: al purgarla, su identificación no queda
> en ningún otro lado. El negocio lo aceptó a sabiendas —el Portal no conserva histórico— y el
> comando **cuenta y reporta** cuántas cayeron en esa condición en cada corrida. Si algún día se
> quiere recuperar el comportamiento conservador, es una línea:
> `PURGA_RETENER_CANCELACIONES=true`.

**La condición de borrado, tal como quedó:**

```sql
-- Fase 1 — reservaciones (van primero: fk_reservacion_solicitud es RESTRICT)
WHERE reservacionFechaSalida < CURDATE()

-- Fase 2 — solicitudes, solo si ya no queda ninguna reservación apuntándolas
WHERE solicitudFechaSalida < CURDATE()
```

**Variables de `.env`** (ninguna obligatoria; los defaults son los pedidos por el negocio):

```dotenv
PURGA_ACTIVA=true
PURGA_LIMITE=500
PURGA_DIAS_GRACIA=0
PURGA_RETENER_CANCELACIONES=false
PURGA_NOTIFICACIONES=false
```

El detalle de cada una, con su razonamiento, está comentado en `config/purga.php`.

**Entrada del planificador** (`routes/console.php`):

```php
Schedule::command('portal:purgar-historico')
    ->dailyAt('00:05')
    ->withoutOverlapping(60)
    ->runInBackground();
```

Verificación de que quedó registrada y de cuándo corre:

```bash
php artisan schedule:list | grep purgar-historico
```

**Requisito previo al despliegue:** ejecutar
`database/sql/rsv_bitacoraaccion_portal.sql` (sustituye a `rsv_bitacoraaccion_purga.sql`).
Desde que la bitácora se escribe **por id** con el helper `bitacora()` —el catálogo ya no se
resuelve por alias—, sin esas filas la FK `fk_bitacora_accion` tumba el INSERT: la purga **ya
no borra en silencio**, retiene el registro y lo reporta como error en su resumen. Los flujos
de invitados responden 500 con rollback. Es intencional: un borrado de datos personales sin
constancia es peor que un borrado que no ocurrió.

> Este proyecto usa `schedule:work` bajo supervisor/NSSM para las tareas de segundos (ver
> `routes/console.php`). La purga es diaria y funciona igual por ese mismo canal; **no requiere
> una entrada de crontab propia**. Si el planificador no está corriendo, la purga simplemente no
> ocurre — y el síntoma es el crecimiento del §5, no un error.

### 7.1 Procedimiento de la PRIMERA purga (obligatorio)

> **Esto es irreversible y borra datos personales de terceros.** No hay papelera.

1. **Respaldo previo del árbol completo**, con fecha en el nombre:
   ```bash
   cd <<RUTA_PROYECTO>>/storage/app/private
   tar czf ~/respaldo-solicitudinvitado-$(date +%F).tar.gz solicitudinvitado
   ls -lh ~/respaldo-solicitudinvitado-*.tar.gz     # confirmar que NO pesa 0
   ```
   Copiar el respaldo **fuera del servidor** antes de continuar.

1.b **Respaldo de la base**, no solo del disco. El alcance ya no es "unos archivos": se borran
   filas de `inv_solicitud`, `inv_invitado`, `rsv_reservacion` y su detalle. Un respaldo del
   árbol de documentos sin su contraparte en base no permite reconstruir nada.

2. **Sembrar el catálogo de bitácora**, una sola vez (y antes de desplegar el código):
   ```bash
   mysql -u USUARIO -p asturias_api < database/sql/rsv_bitacoraaccion_portal.sql
   ```
   Es idempotente: correrlo dos veces no duplica filas. Al terminar, comprobar que las 23
   acciones quedaron con los ids que espera el código:
   ```sql
   SELECT idBitacoraAccion, bitacoraAccionAlias FROM rsv_bitacoraaccion ORDER BY idBitacoraAccion;
   ```

3. **`--dry-run` en producción.** Es lo primero que se corre, siempre:
   ```bash
   php artisan portal:purgar-historico --dry-run
   ```
   Lista **cada reservación y cada solicitud** que borraría —con folio, estado y fecha de
   salida— y cuántos MB liberaría. **No borra nada y no escribe bitácora.**

4. **Revisión a cuatro ojos.** Otra persona lee la salida del `--dry-run` y confirma, renglón por
   renglón, que: (a) **ninguna fecha de salida es de hoy o posterior** —si aparece una, el corte
   está mal y se detiene todo—; (b) el volumen coincide con el orden de magnitud esperado;
   (c) las retenidas lo están por un motivo que se entiende. Si algo no cuadra, **no se corre la
   purga real**: se investiga.

5. **Corrida real**, solo después de (4):
   ```bash
   php artisan portal:purgar-historico
   ```
   En el primer arranque puede haber meses acumulados y el lote está topado: se corre varias
   veces, o se deja que el planificador drene el resto en los días siguientes. El resumen dice
   cuánto quedó.

6. **Verificación posterior:**
   ```bash
   php artisan portal:purgar-historico --dry-run   # debe reportar 0: es idempotente
   du -sh <<RUTA_PROYECTO>>/storage/app/private/solicitudinvitado
   grep -i "purgar-historico" <<RUTA_PROYECTO>>/storage/logs/laravel.log | tail -20
   ```
   Y las dos comprobaciones que demuestran que no se rompió ningún canal:
   ```sql
   -- No debe quedar ninguna marca apuntando a una reservación inexistente.
   SELECT r.idReplicacion FROM rsv_replicacion r
     LEFT JOIN rsv_reservacion v ON v.idReservacion = r.idReservacion
    WHERE v.idReservacion IS NULL;
   ```
   ```bash
   # Tras el siguiente ciclo de Tlalpan (5 min): NO debe aparecer nada nuevo.
   grep "solicitud inexistente" <<RUTA_PROYECTO>>/storage/logs/laravel.log | tail -5
   ```

7. **Conservar el respaldo** al menos un ciclo completo de operación antes de destruirlo.

---

## 8. Rollback

Los cambios de esta entrega son **aditivos**: dos campos nuevos en un JSON, una ruta nueva y dos
variables de `.env` opcionales. Nada existente cambió de forma.

| Situación | Rollback |
|---|---|
| El hash resulta caro en producción | `INVITADOS_DOCUMENTO_HASH=false` + `php artisan config:clear`. **Avisar a Tlalpan** (§3.1). Sin reinicio, sin despliegue |
| El lote falla por el metadato | Revertir `PendienteResource.php` e `InvitadoResource.php` al commit anterior. Tlalpan debe tolerar la ausencia de los campos (los lee con default `null`) |
| La sonda estorba | Comentar la ruta `salud` en `routes/modulos/invitados/transferencia.php` + `php artisan route:clear`. El breaker de Tlalpan reintenta con una descarga real y el diseño funciona igual |
| Rollback total | Revertir los 5 archivos tocados: `PendienteResource.php`, `InvitadoResource.php`, `TransferenciaController.php` (solo el método `salud`), `config/invitados.php`, `routes/modulos/invitados/transferencia.php`. Luego `config:clear` + `route:clear` |

**No hay rollback de datos**: esta entrega no escribe nada. Solo lee el disco.

---

## 9. Diagnóstico

| Síntoma | Causa más probable | Comprobación |
|---|---|---|
| **403** en todo | `INVITADOS_IPS_PERMITIDAS` vacío, o la IP saliente real de Tlalpan no es la configurada | `php artisan tinker` → `config('invitados.ips_permitidas')`. Contrastar con la IP que ve el Portal en el log de acceso |
| **503** | `INVITADOS_HMAC_SECRET` vacío | `php artisan config:clear` y reintentar. Si persiste, el `.env` no tiene el secreto |
| **401** intermitente | Deriva de reloj entre Tlalpan y el Portal, o ventanas distintas en cada lado | `date -u` en ambos servidores. `INVITADOS_VENTANA` debe ser `300` en los dos |
| **409** | Nonce repetido | Es el anti-replay funcionando. Cada petición necesita su propio `X-Nonce` |
| `documento` en `null` para un invitado que **sí** subió foto | El archivo no está donde se espera | `ls -la <<RUTA_PROYECTO>>/storage/app/private/solicitudinvitado/<<ID_SOLICITUD>>/` — el nombre debe ser exactamente `{idInvitado}.{ext}` |
| `documentoSha256` en `null` con `documentoBytes` presente | El hash está apagado, **o** el archivo no se pudo leer | `config('invitados.documentos.hash')`. Si es `true`, revisar permisos del archivo (§2.1) |
| `documentoSha256` no coincide con lo descargado | El socio reemplazó la foto entre el lote y la descarga | **Es el comportamiento correcto**: Tlalpan debe re-descargar. Solo investigar si se repite siempre |
| **404** al descargar un documento | Invitado inexistente, sin solicitud, o sin foto — **el 404 no distingue, a propósito** | Verificar en disco (§2) y en `inv_invitado` cuál de los tres es |
| `pendientes` lento | Lote grande con fotos en el tope y caché de aplicación fría | Medir dos llamadas seguidas: la segunda debe ser notablemente más rápida (memo del hash). Si no, revisar `CACHE_STORE` |

**Log de la aplicación:** `<<RUTA_PROYECTO>>/storage/logs/laravel.log`. Las incidencias de este
canal se registran con el prefijo `invitados.transferencia.`.

---

## 10. Coordinación con Tlalpan

`documento`, `documentoBytes` y `documentoSha256` son **contrato entre dos sistemas**. Cambiar
cualquiera de los tres —nombre, tipo o semántica— **rompe el otro lado**.

Las reglas y el procedimiento de aviso están en
[`NOTA_COORDINACION_DOCUMENTOS_TLALPAN.md`](NOTA_COORDINACION_DOCUMENTOS_TLALPAN.md). **Léala
antes de tocar cualquiera de esos tres campos.**
