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)¶
- La web UI nunca enviaba la categoría
content— faltaba en el tipoApiApplyCategory, en el whitelist del proxy y en el modal (misma clase que el drop silencioso de db/settings). PRs #27, #30, #32, #33. _resolve_prod_post_id_by_slug/_fetch_prod_contentrompían con ruido PHP 8.x — el phar de wp-cli imprimeDeprecated:antes del payload →json.loadsreventaba → 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).- 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=trashsufija el slug afoo__trashed. - 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. - Exclusión automática de settings peligrosos — Google Site Kit (identidad per-env) y plugins de mantenimiento (empujar "maintenance ON" tumbaría prod).
translate_originsextendido a formas bare/URL-encoded del host preview (PR #34). - 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¶
- Sellado (
_wpp_uid): un UUID en post-meta, estampado por el servidor vía el canal exec existente: - 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. - 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. -
Páginas creadas en preview: reciben un
_wpp_uidal crearse; al sincronizarse a prod, la página creada en prod recibe el MISMO_wpp_uid(lo lleva el create). -
Matching:
_LIST_PHP(ambos lados) lee también_wpp_uid._indexempareja 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. -
Rollout strangler (
WPP_IDENTITY_MAP, reusar el patrón existente): off(default): comportamiento actual (slug), cero cambio.shadow: el plan se computa por slug PERO se loguea "¿el matching por UID cambiaría el plan?" (observabilidad, sin tocar prod).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_hostsolo provisiona infra (LXC/PG/redis/docker/systemd) + un unit conExecStartplaceholder; 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 enservices/wp-pulse-server/DEPLOY.md. Fix propio: una tareaapp_deploy.ymlen el rol + reconciliar los units systemd (server real + web) con la realidad.