Skip to content

ADR-0023 — WP-Pulse: promoción de contenido preview→prod, hardening del apply, e identidad durable

Status: Parcialmente aplicado (2026-07-13) — el fix de promoción de contenido + el hardening del apply están live en uruguaypolitico.com (LXC 281); la identidad durable (§Decision) queda diseñada y NO implementada (proyecto aparte, off-by-default). Date: 2026-07-13 Deciders: Ramón Kamibayashi + Claude Opus 4.8 Related: ADR-0013 (apply-to-prod unificado), ADR-0020 (convergencia del pipeline), ADR-0021 (sync declarativo), el módulo strangler identity_map.py (WPP_IDENTITY_MAP off/shadow/on).


Context

uruguaypolitico.com tenía 28 páginas de figuras políticas editadas + 1 nueva atascadas en preview desde el alta (2026-07-07): el "Apply all to prod" reportaba éxito pero el contenido nunca llegaba a prod. La sesión encontró dos bugs encadenados y, al endurecer el arreglo, una serie de defectos que se cazaron con cuatro rondas de review adversarial (9 agentes revisores).

Lo que se arregló (live)

  1. La web UI nunca enviaba la categoría content — faltaba en el tipo ApiApplyCategory, en el whitelist del proxy y en el modal (misma clase que el drop silencioso de db/settings). PRs #27, #30, #32, #33.
  2. _resolve_prod_post_id_by_slug / _fetch_prod_content rompían con ruido PHP 8.x — el phar de wp-cli imprime Deprecated: antes del payload → json.loads reventaba → toda página Elementor daba "prod post not found". Fix: _strip_php_diagnostics (solo Elementor en _fetch_prod_content, para no cortar líneas de content WPBakery).
  3. Idempotencia del create sin adoptar contenido del operador — un create que falla tras existir el post se ROLLBACKea (force-delete con fallback a trash, ambos liberan el slug bare; un revive-desde-trash solo re-trashea). Un draft/publish del operador se RECHAZA, nunca se sobrescribe. Verificado empíricamente contra prod que wp post update --post_status=trash sufija el slug a foo__trashed.
  4. Retry de transitorios en push_exec — reintenta fallos pre-envío (ConnectError/DNS, exactly-once-safe) + post-envío solo con id idempotente; connect timeout acotado a 10s.
  5. Exclusión automática de settings peligrosos — Google Site Kit (identidad per-env) y plugins de mantenimiento (empujar "maintenance ON" tumbaría prod). translate_origins extendido a formas bare/URL-encoded del host preview (PR #34).
  6. Content inline (no background) — un intento de correr el content sync en background introdujo 4 regresiones (ApplyRun prematuro, warning erróneo, cache-purge antes de tiempo, sin recuperación ante crash); se revirtió. El content inline completa aunque CF corte a 100s (el handler no se cancela al desconectar el cliente) → el 524 es cosmético.

El problema residual: matching por slug

El plan de contenido (content_state_sync) empareja preview↔prod por slug ({post_type}:{slug}). Cuando el slug de preview deriva del de prod, el plan no los empareja y propone un create → duplicado. Caso real (Guido Manini Ríos): un attachment en preview robó el slug limpio guido-manini-rios, así que la página quedó en guido-manini-rios-2, que no casa con la guido-manini-rios publicada en prod. Resolverlo requirió una decisión editorial humana (volcar sobre la URL limpia vs crear -2) — justo lo que el objetivo "push a prod totalmente automático" quiere eliminar.

El identity_map.py existente NO resuelve esto: su stable_key_for_post también es slug-based.

Decision

Adoptar una identidad durable per-post cross-environment (_wpp_uid) y emparejar contenido por ella, con rollout strangler (off → shadow → on). Enteramente server-side — no requiere cambios en el agente de los sitios de clientes.

Diseño

  1. Sellado (_wpp_uid): un UUID en post-meta, estampado por el servidor vía el canal exec existente:
  2. Backfill (una vez por sitio, endpoint admin): wp post meta update <id> _wpp_uid <uuid> sobre cada post/página publicada de prod que no lo tenga.
  3. En cada pull: como el preview es un clon del dump de prod, hereda el _wpp_uid. Los posts nuevos de prod se sellan en el pull siguiente.
  4. Páginas creadas en preview: reciben un _wpp_uid al crearse; al sincronizarse a prod, la página creada en prod recibe el MISMO _wpp_uid (lo lleva el create).

  5. Matching: _LIST_PHP (ambos lados) lee también _wpp_uid. _index empareja primero por _wpp_uid, con fallback a slug para posts aún sin sellar. Así un slug que deriva (-2) sigue casando con su prod por UID → UPDATE sobre la URL limpia, sin duplicado, sin decisión editorial.

  6. Rollout strangler (WPP_IDENTITY_MAP, reusar el patrón existente):

  7. off (default): comportamiento actual (slug), cero cambio.
  8. shadow: el plan se computa por slug PERO se loguea "¿el matching por UID cambiaría el plan?" (observabilidad, sin tocar prod).
  9. on: matching por UID primario, slug de fallback.

Flip a shadow → observar acuerdo → flip a on con su propio ciclo de verificación.

Por qué NO se implementó en esta sesión

Reescribir el matching de contenido es el núcleo que cuatro pasadas de review acaban de verificar sólido en su forma slug-based. Meterlo en caliente, sin su propio ciclo de review + rollout shadow, introduciría más riesgo que el slug-drift que resuelve — y ese riesgo ya está desactivado por la idempotencia (un -2 es additivo y seguro; "qué URL es canónica" es una decisión editorial legítima, no un bug). Se hace como proyecto propio cuando el slug-drift reaparezca o cuando se priorice el "cero decisiones humanas".

Consequences

  • Positivo: elimina de raíz la clase slug-drift → duplicado (el único punto que aún pedía criterio humano en el push). Server-side puro (sin desplegar agente a sitios de clientes). Rollout observable y reversible (strangler).
  • Coste: +1 meta por post; +1 lectura en _LIST_PHP; un backfill por sitio; y un ciclo de review/shadow antes de activar. El fallback a slug mantiene compatibilidad durante la transición.
  • Riesgo si se apura: cambia el matching del path de prod recién estabilizado → obligatorio shadow-first.

Follow-ups relacionados (registro)

  • Fuga de host-preview en translate_origins: cerrada para formas bare + URL-encoded (PR #34). El caso concreto conocido (Site Kit) ya estaba excluido por nombre.
  • Higiene de deploy (abierto): el rol Ansible wp_pulse_host solo provisiona infra (LXC/PG/redis/docker/systemd) + un unit con ExecStart placeholder; el código de la app se despliega a mano (rsync a /opt/wp-pulse-server, uv sync, pnpm build, restart) — el box es un snowflake reproducible solo por conocimiento tribal. Documentado en services/wp-pulse-server/DEPLOY.md. Fix propio: una tarea app_deploy.yml en el rol + reconciliar los units systemd (server real + web) con la realidad.