# Phase 0 — Research: index-relay MVP

**Feature**: 001-gsc-sitemap-coverage
**Date**: 2026-08-10

Cada decisión resuelve un punto que el spec dejó abierto o que el stack elegido obliga a fijar
antes de diseñar el modelo de datos.

---

## R1 — Acceso a las propiedades de Search Console

**Decision**: una única cuenta de servicio de Google Cloud, cuyo `client_email` se agrega en cada
propiedad de Search Console como **propietario delegado** (*delegated owner*), no como usuario
con permiso de lectura ni como usuario completo.

**Rationale**: el URL Inspection API exige nivel de propietario sobre la propiedad; con permiso
de solo lectura las llamadas fallan. La cuenta de servicio evita por completo la pantalla de
consentimiento, la verificación de la aplicación ante Google y el ciclo de vida de los
`refresh_token`. Como el primer corte opera sitios propios, quien administra la plataforma
también administra el Search Console, así que agregar el propietario delegado es un paso manual
de una sola vez por dominio.

**Alternatives considered**:

- *Acceso delegado por usuario (OAuth 3-legged)*: descartado por decisión explícita de alcance.
  Obliga a publicar y verificar la aplicación ante Google, a manejar renovación y revocación de
  tokens, y no aporta nada mientras los sitios sean propios. Queda previsto en el modelo de
  datos (FR-038) para no migrar después.
- *Domain-wide delegation de Workspace*: descartado. Resuelve un problema distinto (suplantar
  usuarios de un dominio de Workspace) y exige un tenant de Workspace.

**Consecuencia operativa**: el mensaje que la plataforma le muestra al operador (FR-005) debe
decir textualmente "agregar como propietario", porque "agregar como usuario" produce un fallo
silencioso y difícil de diagnosticar.

---

## R2 — Permisos solicitados

**Decision**: un único scope, `https://www.googleapis.com/auth/webmasters`.

**Rationale**: `webmasters.readonly` alcanza para inspeccionar URLs y listar sitemaps, pero
**no** para enviarlos. Como US2 requiere el envío de sitemaps, hace falta el scope de escritura.
Es el mínimo que cubre las dos operaciones del producto.

**Alternatives considered**: pedir ambos scopes y elegir según la operación — descartado por
complejidad sin beneficio, ya que un solo credencial atiende ambos casos.

---

## R3 — Forma de la propiedad

**Decision**: el dominio guarda explícitamente su identificador de propiedad tal como lo espera
la API, en uno de dos formatos: `sc-domain:ejemplo.com` para propiedad de dominio, o
`https://ejemplo.com/` para prefijo de URL. Se pide al dar de alta y se valida contra el listado
de propiedades accesibles.

**Rationale**: es la causa más común de "la API no encuentra la propiedad". Los dos formatos no
son intercambiables y el prefijo de URL distingue esquema, subdominio y barra final. Adivinarlo
produce errores confusos; pedirlo y validarlo produce un error accionable (FR-006).

**Alternatives considered**: derivarlo del dominio automáticamente — descartado, porque un mismo
sitio puede tener ambas propiedades registradas con distinto alcance.

---

## R4 — Traducción del estado de indexación

**Decision**: por cada inspección se persisten **los campos crudos** que devuelve Google
(`verdict`, `coverageState`, `robotsTxtState`, `indexingState`, `pageFetchState`,
`googleCanonical`, `userCanonical`, `lastCrawlTime`) junto con la marca de tiempo de obtención, y
además un estado interno derivado para poder filtrar y agregar.

El estado interno es un conjunto cerrado: `UNKNOWN`, `INDEXED`, `CRAWLED_NOT_INDEXED`,
`DISCOVERED_NOT_INDEXED`, `DUPLICATE_CANONICAL`, `EXCLUDED_NOINDEX`, `BLOCKED_ROBOTS`,
`FETCH_ERROR`, `REDIRECT`, `OTHER_NOT_INDEXED`.

**Regla de traducción a prueba de fallos**: sólo se deriva `INDEXED` cuando el veredicto es
`PASS`. Cualquier `coverageState` no reconocido cae en `OTHER_NOT_INDEXED`, **nunca** en
`INDEXED` ni en `UNKNOWN`. `UNKNOWN` se reserva exclusivamente para URLs que jamás se
inspeccionaron.

**Rationale**: `coverageState` es una cadena legible por humanos que Google puede cambiar o
localizar sin aviso. Si el mapeo fallara hacia el lado optimista, la plataforma reportaría como
indexada una URL que no lo está, que es exactamente lo que prohíbe el principio I de la
constitución. Guardar el crudo permite además recalcular el estado derivado a posteriori sin
volver a gastar cuota.

**Alternatives considered**: guardar sólo el estado derivado — descartado, hace irreversible
cualquier error de mapeo y obliga a reinspeccionar (gastando cuota) para corregirlo.

---

## R5 — Presupuesto de cuota

**Decision**: un registro de presupuesto por dominio y por día en PostgreSQL, con reserva
atómica mediante `SELECT ... FOR UPDATE` antes de cada llamada, y liberación si la llamada no
llegó a ejecutarse. Dos cupos separados: automático y reserva manual. Límite por defecto 2.000
por día por propiedad, configurable. Se aplica además un limitador de ritmo de 600 por minuto.

**Rationale**: el principio II exige que ninguna llamada ocurra fuera del control de
presupuesto, y que el consumo sea auditable. PostgreSQL da durabilidad y trazabilidad; un
contador sólo en memoria o en Redis se pierde ante un reinicio y deja el consumo real sin
registro. El costo de una fila por dominio y día es despreciable.

**Alternatives considered**:

- *Contador en Redis*: más rápido, pero volátil y sin historia. Se usa igual como caché del
  limitador por minuto, no como fuente de verdad del cupo diario.
- *Confiar en el `429` de Google*: descartado. Reaccionar al bloqueo ya significa haber
  perjudicado al dueño del sitio.

---

## R6 — Idempotencia

**Decision**: tres mecanismos complementarios.

1. **API**: cabecera `Idempotency-Key` opcional; si viene, se guarda la respuesta asociada a esa
   clave por 24 horas y un reintento con la misma clave devuelve la respuesta original sin
   ejecutar trabajo nuevo.
2. **Sitemaps**: se guarda una huella del contenido (hash) de cada sitemap; un envío sólo ocurre
   si la huella cambió respecto del último envío exitoso.
3. **Tareas asíncronas**: cada tarea recibe el identificador de su lote y comprueba el estado
   antes de actuar; un lote ya terminado no se reprocesa y no reenvía correos.

**Rationale**: cubre las tres fuentes reales de duplicación — reintento del cliente, reejecución
del pipeline y reentrega del broker de mensajes.

---

## R7 — Cómo se implementa "cableado y apagado"

**Decision**: una función de política de límites, `limits.check(account, action, amount)`, que se
invoca en el alta de dominio, en el alta de URLs monitoreadas y en el encolado de lotes. En el
primer corte resuelve contra un plan sin límites y siempre autoriza, pero registra el consumo.
Su activación se controla con `BILLING_ENABLED` (por defecto `False`), igual que
`TXT_VERIFICATION_ENABLED` (por defecto `False`). Ambos flags se imprimen en el arranque.

**Rationale**: es la traducción directa del principio VI. El costo hoy son tres llamadas y una
función que devuelve autorización; el ahorro mañana es no tener que abrir los flujos de alta ni
inventar el historial de consumo que nunca se guardó.

**Verificación**: hay tests que comprueban tres cosas — que los flags están apagados por defecto,
que el punto de decisión se invoca igual estando apagado, y que ninguna respuesta de la API ni
plantilla de correo menciona planes, precios o verificación TXT.

---

## R8 — Programación del ciclo diario

**Decision**: Celery con Redis como broker y `celery beat` para el disparo diario, una tarea por
dominio. La tarea planificadora crea el lote y encola tareas de inspección en trozos, en vez de
una tarea larga por dominio.

**Rationale**: trozos cortos sobreviven a reinicios, permiten reportar progreso real (FR-017) y
respetan naturalmente el limitador por minuto. Una tarea monolítica de 2.000 llamadas no puede
reanudarse ni informar avance.

**Alternatives considered**: `cron` del sistema invocando un comando de Django — descartado,
porque el trabajo ya necesita una cola para el resto de las operaciones y tener dos mecanismos
de ejecución duplica el modo de fallo.

---

## R9 — Interfaz de usuario y el principio IV

**Decision**: la interfaz se construye con **Inertia sobre React**, servida por Django mediante
`inertia-django`, con Vite para los assets. Las vistas de Inertia y los endpoints de la API
delegan en **la misma capa de servicios**; ninguna vista contiene lógica de negocio propia. Un
test de paridad verifica que toda operación de negocio expuesta en la interfaz tiene su endpoint
equivalente en la API.

**Rationale**: tres razones convergen.

1. **La historia que más importa lo pide.** US1 y US4 son un asistente por pasos con
   comprobaciones contra Google en vivo, carga de archivo y mensajes de error accionables. Con
   recarga completa de página, cada comprobación reinicia el contexto visual del usuario, que es
   exactamente a quien estamos tratando de no perder.
2. **Inertia elimina la API paralela.** El problema clásico de una interfaz en React es tener que
   construir y versionar endpoints sólo para ella. Inertia entrega los datos como props desde la
   vista, así que la API pública sigue existiendo para su propósito real —la integración desde
   pipelines— sin cargar con las necesidades de la pantalla.
3. **La curva de aprendizaje queda de un solo lado.** El equipo ya trabaja con Inertia y React
   sobre Laravel; el patrón es idéntico. Lo nuevo se limita a Django.

**Cumplimiento del principio IV**: Inertia no consume la API por HTTP, así que la lectura literal
del principio no se satisface. Se cumple su exigencia real —cero lógica de negocio en la
interfaz y paridad total con la API— mediante la capa de servicios compartida y el test de
paridad. Queda registrado en Complexity Tracking del plan.

**Alternatives considered**:

- *Plantillas de Django con HTMX*: evita la cadena de compilación, pero desaprovecha la
  experiencia previa del equipo y obliga a resolver el asistente con fragmentos de HTML.
- *Plantillas de Django puras*: lo más barato de escribir, pero convierte cada comprobación del
  asistente en una recarga completa.
- *Aplicación de página única separada consumiendo la API*: cumple el principio al pie de la
  letra, a costa de dos despliegues, CORS y sesión compartida entre dominios.

**Consecuencia operativa**: aparece una etapa de compilación de assets en el despliegue y un
servidor de Vite en desarrollo. Se resuelve con `django-vite`, que distingue ambos modos por
configuración.

**Mecanismo verificado** (leído del paquete instalado, no supuesto):

- Una vista devuelve `inertia.render(request, 'NombreDePagina', props)`. Ese nombre se resuelve
  contra `frontend/pages/NombreDePagina.tsx`.
- El paquete trae su propia plantilla, que hace `{% extends inertia_layout %}` y rellena el
  bloque `inertia` con el `<script data-page>` y el `<div id="app">`. Por eso la plantilla base
  del proyecto sólo declara ese bloque: el contenido lo pone Inertia.
- `INERTIA_LAYOUT` apunta a esa plantilla base. Si falta, el paquete usa la suya.
- `django-vite` acepta `dev_mode`, `dev_server_host`, `dev_server_port`, `static_url_prefix` y
  `manifest_path` dentro de la clave `default`.

---

## R10 — Pruebas contra Google

**Decision**: `pytest` con `pytest-django`. Las respuestas de Google se graban una vez y se
reproducen en los tests mediante dobles de prueba a nivel del cliente HTTP. La suite automatizada
no toca la red.

**Rationale**: lo exige el flujo de trabajo de la constitución. Además, las llamadas reales
gastarían cuota real de propiedades reales cada vez que corre la suite.

**Alternatives considered**: un entorno de pruebas de Google — no existe para estas APIs.

---

## R11 — Documentación de la API

**Decision**: `drf-spectacular` genera el esquema OpenAPI desde los serializadores y las vistas,
y se publica una página navegable junto con la aplicación. El esquema generado se compara en CI
contra el contrato versionado en `contracts/openapi.yaml`; una diferencia no declarada falla la
compilación.

**Rationale**: el principio IV trata la documentación desactualizada como bug de severidad alta.
Generarla del código y verificarla contra el contrato hace imposible que se desincronicen en
silencio.

---

## R12 — Custodia de la clave de la cuenta de servicio

**Decision**: la clave se entrega por variable de entorno o por archivo montado en el
contenedor, nunca en la imagen ni en el repositorio. Un filtro de registro elimina las claves
conocidas de los logs. El arranque falla de forma explícita si la credencial falta o es
ilegible, en vez de degradarse.

**Rationale**: principio III. Fallar al arrancar es preferible a operar sin poder consultar y
llenar la base de estados desconocidos.

---

## R13 — Volumen y almacenamiento

**Decision**: tablas relacionales normales con índices sobre `(domain, coverage_state)`,
`(domain, last_checked_at)` y `(url_id, fetched_at)`. Sin particionado ni almacén de series
temporales. El historial de cobertura sólo guarda **cambios de estado**, no una fila por
inspección.

**Rationale**: la escala declarada es de decenas de dominios y cientos de miles de URLs. Guardar
sólo los cambios reduce el historial en uno o dos órdenes de magnitud respecto de guardar cada
inspección, sin perder la capacidad de responder cuándo una URL dejó de estar indexada (FR-019).
El particionado se evalúa cuando el volumen lo justifique, no antes (principio V).

---

## R14 — Cifrado de la clave, no hash

**Decision**: el contenido de la clave de la cuenta de servicio se guarda **cifrado de forma
reversible** con Fernet, usando una clave de cifrado provista por entorno y ajena al repositorio.
Se derivan y guardan en claro tres datos no secretos para poder identificarla en pantalla: la
dirección de la cuenta de servicio, el identificador de clave que informa Google y una huella.
Las claves de API de la plataforma, en cambio, se guardan **hasheadas**.

**Rationale**: son dos secretos con usos opuestos. La clave de la cuenta de servicio hay que
**usarla** en cada llamada para firmar el intercambio con Google, así que el sistema necesita
recuperar su valor: un hash la volvería inservible. Una clave de API de la plataforma sólo se
**compara** contra lo que envía el cliente, así que el hash es correcto y es lo que impide que
una filtración de la base entregue credenciales utilizables.

**Consecuencia**: la clave de cifrado es un secreto de despliegue de primer nivel. Su pérdida
inutiliza todas las credenciales guardadas —hay que volver a cargarlas—, y su filtración
compromete todas. Se documenta el procedimiento de rotación: descifrar con la clave vieja y
recifrar con la nueva, sin exponer el material en el proceso.

**Alternatives considered**:

- *Guardar la clave en claro*: descartado, incumple el principio III.
- *No guardarla y pedirla en cada operación*: descartado, impide el trabajo asíncrono programado,
  que es la razón de ser del producto.
- *Gestor de secretos externo por credencial*: mejor a escala, desproporcionado para el primer
  corte. El campo cifrado puede migrarse a una referencia externa sin cambiar el flujo.

---

## R15 — Credenciales organizadas por módulo

**Decision**: se introducen tres piezas — `GoogleProject`, `Module` y una tabla de asignación
`ModuleCredential`— en vez de colgar la credencial directamente del dominio. La credencial con la
que se consulta un dominio se resuelve por módulo en tiempo de uso.

**Rationale**: es lo que permite incorporar módulos nuevos sin migración y que una misma
credencial sirva a varios, tal como se pidió. Además resuelve un problema concreto de hoy:
reemplazar una credencial no debe obligar a actualizar cada dominio. Al resolverla por módulo, el
reemplazo es una sola escritura y los dominios quedan intactos (FR-012).

**Alternatives considered**: un campo de credencial en cada dominio — más simple de leer, pero
convierte el reemplazo de credencial en una actualización masiva y hace que un fallo a mitad de
camino deje dominios apuntando a una credencial muerta.

---

## R16 — Guía con verificación efectiva, no con casillas

**Decision**: cada paso de la puesta en marcha tiene una comprobación real de su requisito, y el
paso se marca cumplido sólo si esa comprobación pasa. El texto de la guía vive en un único módulo
de contenido, separado del flujo, y cada paso declara su objetivo además de la ruta exacta dentro
de las pantallas de Google.

**Rationale**: una guía que sólo avanza con "siguiente" traslada al usuario la carga de saber si
lo hizo bien, que es justamente lo que no sabe. El valor está en que la plataforma responda "esto
todavía no está, y es por esto". Separar el contenido del flujo permite corregir la guía cuando
Google cambie sus pantallas sin tocar la lógica; declarar el objetivo además de la ruta hace que
el paso siga siendo comprensible aunque la ruta haya quedado vieja.

**Comprobaciones por paso**: formato del identificador de proyecto (local, sin llamada externa);
validez estructural del archivo de clave (local); credencial utilizable, API habilitada y
correspondencia entre proyecto y clave (contra Google); acceso a la propiedad (contra Google);
sitemap descargable y bien formado (contra el sitio); lote encolado (interno).

---

## R17 — Mostrar un ejemplo del archivo que hay que reconocer

**Decision**: el paso de la clave muestra un ejemplo anonimizado del archivo JSON de cuenta de
servicio, con los campos característicos resaltados —`type: "service_account"`, `project_id`,
`private_key_id`, `client_email`— y una advertencia explícita de que una API key de Google no
sirve para este fin. El ejemplo es contenido estático sin material secreto real.

**Rationale**: el usuario que nunca entró a Google Cloud no sabe qué archivo está buscando, y la
consola ofrece varios tipos de credenciales con nombres parecidos. Ver la forma del archivo
correcto antes de subirlo evita el ciclo de subir el equivocado, recibir un error y no saber qué
cambiar. Complementa la validación estructural: el ejemplo previene el error, la validación lo
detecta.

**Consecuencia de diseño**: la validación del archivo debe producir un mensaje que nombre el
campo faltante o el tipo encontrado, no un "archivo inválido" genérico.

---

## R18 — Hallazgos de la verificación del andamiaje

**Contexto**: la fase de andamiaje se escribió primero a mano, con versiones y configuraciones
tomadas de memoria, y se descartó por completo al comprobar que nada de eso estaba verificado. Se
rehízo generando cada pieza con su herramienta oficial. Lo que sigue es lo que sólo apareció al
ejecutar, y queda registrado para no volver a tropezar.

**Decision**: el andamiaje se genera con `django-admin startproject`, `manage.py startapp`,
`create-vite` y el inicializador de shadcn; las versiones las resuelven `uv` y `npm`. Ninguna
tarea se cierra sin haberla ejecutado. Ambas reglas quedaron incorporadas al flujo de trabajo de
la constitución en su versión 1.2.0.

**Hallazgos concretos**:

| Hallazgo | Consecuencia |
|---|---|
| **Django 6 configura el correo con `MAILERS`**, no con `EMAIL_BACKEND` | La configuración de correo se declara como diccionario de mailers |
| **Los comentarios `{# #}` de Django son de una sola línea** | Los multilínea viajan al cliente. Se usan bloques `{% comment %}` |
| **El orden de las capas de configuración importa** | La capa base se evalúa entera antes que la hija: si base exige una variable, la hija nunca llega a dar su respaldo. El respaldo vive en base y producción lo redefine sin valor por omisión |
| **`create-vite` 9 ignora `--template`** | Hay que usar `-t react`. Con la forma vieja genera un proyecto sin React |
| **El inicializador de shadcn es interactivo** | Requiere `-t vite -b radix -p nova` para correr sin intervención |
| **Ninguna versión recordada era correcta** | Django estaba en 6.1 y no en 5.x; el tope `<6.0` que se había escrito de memoria la habría bloqueado |

**Rationale**: cinco de estos seis puntos son invisibles leyendo el código y sólo aparecen al
correrlo. El costo de descubrirlos tarde crece con cada archivo que se apila encima.

---

## R19 — Rutas nombradas en el frontend

**Contexto**: en Laravel, Ziggy expone las rutas nombradas al navegador y permite escribir
`route('domains.show', id)` en un componente. Django no tiene equivalente, y el plan define doce
vistas con parámetros: sin resolución por nombre, cada URL queda escrita a mano en el JSX y
renombrar una ruta obliga a buscarla por todo el frontend.

**Decision**: helper propio. Las rutas nombradas se serializan del resolvedor de URLs de Django,
viajan como prop compartida de Inertia y se resuelven en el cliente con una función tipada.

**Rationale**: se evaluaron las dos únicas alternativas del ecosistema y ninguna es viable.

| Paquete | Versión | Situación |
|---|---|---|
| `django-js-reverse` | 1.0.0, mayo de 2026 | Declara `Django<6.1,>=5.2`: **excluye la versión que usa el proyecto**. Instalarlo obligaría a forzar la resolución o a quedarse en Django 6.0 |
| `django-js-routes` | 0.2.0, diciembre de 2021 | Sin publicaciones en casi cinco años y sin clasificadores para versiones actuales de Django |

Además, ambas resuelven el problema con un script aparte servido por Django, que con Inertia es
redundante: ya existe un canal para mandar datos del servidor al cliente en cada navegación.

**Diseño**:

1. `apps/core/routes.py` recorre el resolvedor de URLs y produce un diccionario de nombre a
   patrón, filtrando lo que no debe salir —el admin y los internos—, con una lista explícita de
   qué se expone en vez de exponer todo por omisión.
2. Ese diccionario viaja como prop compartida de Inertia, así que llega en cada navegación sin
   pedir un archivo aparte ni desincronizarse tras un despliegue.
3. `frontend/lib/routes.ts` expone `route(name, params)`, que rellena el patrón y falla con un
   mensaje claro si falta un parámetro obligatorio.
4. Los nombres de ruta se declaran como un tipo unión, de modo que `route('dominios.detalle')`
   con un nombre inexistente sea un error de compilación y no un enlace roto en producción.

**Ventajas sobre las librerías descartadas**: cero dependencias que puedan quedar sin
mantenimiento, tipado de nombres y parámetros —que ninguna de las dos ofrece—, y control
explícito de qué rutas se publican, que importa porque exponer el mapa completo de URLs de una
aplicación es información de más para el navegador.

**Costo**: alrededor de cincuenta líneas entre servidor y cliente, más su test.

**Alternatives considered**: pasar las URLs ya armadas en las props de cada vista — no requiere
infraestructura, pero obliga a acordarse de incluir cada URL y deja fuera las que el cliente
arma solo, como la paginación o los filtros.

---

## R20 — Hallazgos de la implementación de Foundational, US1, US2 y US3

**Contexto**: seis defectos que ningún documento anticipó y que sólo aparecieron al ejecutar el
código o al abrirlo en un navegador. Cinco de los seis pasaban `manage.py check`, `ruff` y la
suite sin quejarse.

**Decision**: cada uno queda con su test de regresión. Los dos últimos, además, cambian cómo se
verifica: el navegador hay que recorrerlo **también sin sesión**, porque ése es el primer camino
de cualquiera.

**Hallazgos concretos**:

| Hallazgo | Por qué no se veía | Consecuencia |
|---|---|---|
| **El cliente de Google armaba la petición antes de validar la reserva de cupo** | Armar la petición construye el servicio, y construir el servicio descifra la credencial: el trabajo con el material de la clave ya había ocurrido | `_execute` recibe una función que arma la petición y sólo la invoca con la reserva ya concedida. El test de la guardia usa un doble que protesta si lo tocan |
| **El secreto de una clave de API va en base64 url-safe, y ese alfabeto incluye el guion bajo** | Partir por todos los guiones bajos rechazaba, al azar, las claves que por sorteo tenían uno adentro | El corte es en los dos primeros separadores. Hay test con una clave que lleva guiones bajos en el secreto |
| **Inertia trae los nombres de Laravel para el testigo anti-CSRF** | Django usa `csrftoken` y `X-CSRFToken`; con los de fábrica toda escritura devolvía 403 sin explicación | Se declaran en `createInertiaApp`. Además Django sólo emite la cookie cuando una plantilla la pide, así que el middleware de props compartidas la fuerza en toda respuesta |
| **Inertia envía los formularios como JSON salvo que haya un archivo** | Django sólo llena `request.POST` con multipart: con JSON la vista concluía que no se mandó nada, con el formulario visiblemente completo | Un lector único en `apps/core/requests.py` que atiende los dos casos |
| **Faltaba declarar `LOGIN_URL`** | El valor de fábrica es `/accounts/login/`, que no existe acá: todo visitante sin sesión caía en un 404 en la puerta de entrada | Se declara apuntando a `login`. El test recorre **todas** las rutas protegidas, así que una ruta nueva entra sola |
| **El formulario de ingreso envía a `/login` sin la cadena de consulta** | El destino viajaba en la dirección pero se perdía en el envío, y quien llegaba por un enlace terminaba en el listado | El destino viaja como prop y la página lo reenvía como campo. Un intento fallido tampoco lo pierde |

**Rationale**: los cuatro primeros son de integración entre piezas que por separado funcionan, y
sólo se manifiestan cuando hablan entre sí. Los dos últimos son de un camino que la verificación
no recorría: toda la revisión en el navegador se había hecho con la sesión ya iniciada.

**Alternatives considered**: dejar la validación de cupo dentro del cliente pero después de armar
la petición —descartada porque la garantía del principio II tiene que ser estructural y no de
orden accidental—; y usar `forceFormData` en cada formulario para esquivar el problema del JSON
—descartada porque obliga a acordarse en cada pantalla, que es exactamente lo que un lector único
evita—.

## Fuentes

- [Search Console API — Usage Limits](https://developers.google.com/webmaster-tools/limits)
- [Method: index.inspect](https://developers.google.com/webmaster-tools/v1/urlInspection.index/inspect)
- [UrlInspectionResult](https://developers.google.com/webmaster-tools/v1/urlInspection.index/UrlInspectionResult)
- [Managing owners, users, and permissions — Search Console Help](https://support.google.com/webmasters/answer/7687615?hl=en)
- [Prerequisites — delegated owner para cuentas de servicio](https://developers.google.com/search/apis/indexing-api/v3/prereqs)
