Skip to content

ADR-0010: WP-Pulse — Control Plane + Agente WordPress para Dev Bridge Multi-Cliente

Status: Accepted — v1.0 shipped 2026-05-30. F0+F1+F3+F4+F-preview+F-versions+F-schedules+F-fleet+F-dashboard all live on hotelaldamagolf.com. Date: 2026-05-29 Deciders: Ramón Kamibayashi + Claude Opus 4.7 Related: ADR-0008 Remote-Pulse (patrón reutilizado), ADR-0007 Homelab Cohesion (SOPS+age, Caddy HA, PocketID) Technical Story: Plataforma agente+servidor para gestionar 1-to-N WordPress de clientes (que ya están en producción y no se construyeron aquí) con: inventario reproducible por sitio, entorno local Docker fiel al de producción, push de código con preview agentic y aprobación humana, drift dashboard cross-sitios y orquestación de actualizaciones. Patrón Remote-Pulse aplicado al vertical WordPress.


Context

La actividad freelance/agencia personal acumula WordPress de clientes en hostings variados (SiteGround, Kinsta, WP Engine, cPanel shared, VPS propios) que no fueron construidos en este homelab — son sitios vivos heredados o mantenidos. Cada intervención hoy es manual, frágil y sin trazabilidad:

  1. Onboarding por sitio: SSH ad-hoc o FTP, descargar zip de wp-content, mysqldump manual, levantar LocalWP/MAMP/Docker a mano, search-replace de URLs olvidando que WordPress serializa PHP en wp_options. ~45min por sitio nuevo, frecuentemente roto.
  2. Plugins premium (Elementor Pro, ACF Pro, WPML, Gravity Forms, Woo extensions): licencias atadas a dominio, callbacks anti-piratería, ZIPs no redistribuibles. Recrear el sitio en local pierde funcionalidad o invalida la licencia de producción si el plugin llama a casa con el dominio local.
  3. Uploads (wp-content/uploads): sitios reales 5-50 GB. Copiar al pull es absurdo (40+ min) y rompe el flow iterativo.
  4. Push de cambios: rsync manual de theme/plugin, sin diff previo, sin backup automático, sin rollback. Un día se pisa producción con un fichero que no era.
  5. Sin trazabilidad cross-sitio: ¿qué versión de PHP corre cada cliente? ¿Yoast está actualizado en los 12? ¿Algún sitio tiene errores PHP nuevos hoy? Hoy se contesta a base de SSH-loop manual.
  6. Sin entorno IA seguro: Claude Code / Codex pueden trabajar sobre el código, pero no hay un worktree aislado por sitio con preview público + visual diff + approval flow antes de tocar producción. Resultado: agente IA queda fuera del workflow cliente, o se usa con miedo.
  7. Cron, mail, Stripe live keys en local: levantar un WordPress real en local sin precauciones manda emails de verdad, ejecuta cron de verdad, llama a Stripe live de verdad. Riesgo recurrente.

Restricciones:

  • El control plane debe correr en infraestructura existente del homelab (LXC en pmx-50), no en Mac local — para que sobreviva al portátil cerrado y los preview tunnels estén siempre disponibles.
  • El agente WordPress debe ser instalable en hostings restrictivos sin IP fija, detrás de Cloudflare/WAFs, sin port-forwarding ni VPN cliente. Outbound polling obligatorio como mecanismo primario.
  • Reutilizar patrón Remote-Pulse: Ed25519 firma, Postgres+TimescaleDB, FastAPI server, Next.js web, Tailscale Funnel para previews públicos, SOPS+age para secretos.
  • Claude Code (CLI nativo, ya usado por el operador vía SSH/Tailscale) debe poder trabajar contra el worktree de cada sitio sin reinventar UI agentic propia.
  • El push de base de datos a producción es legal pero rodeado de safety rails fortísimas (backup + dry-run + confirmación). Nunca jamás automático.
  • Plugin del agente WP arranca privado (monxas/wp-pulse-agent) con opción a FOSS posterior si madura.
  • Single-user inicialmente (operador = Ramón). Multi-tenant queda como hook arquitectónico, no como requisito funcional v1.

Decision Drivers (prioridad descendente)

  1. Producción intocable por defecto — toda mutación de prod requiere acción explícita; el sistema hace imposible que un comando ambiguo (o un agente IA) tire producción accidentalmente.
  2. Onboarding de sitio nuevo <5 min — pegar URL + credencial → plugin auto-instalado → primer pull funcional con sitio levantado en local. Cero pasos manuales post-input inicial.
  3. Fidelidad local↔prod — versión PHP, MySQL/MariaDB, extensiones, límites ini, plugins (incluido premium dev-mode), tema, HTTPS, uploads. El bug que ves local es el bug que ocurre en prod.
  4. Outbound polling con fallback inbound — agente WP funciona detrás de WAF/CDN/CGNAT sin pedir cambios al hosting. Ed25519 polling default, REST inbound (app password WP) configurable por sitio cuando el hosting permite.
  5. Git como source of truth del filesystem — cada sitio = repo git local desde el pull. Diffs naturales, branches por feature, agentes IA trabajan en worktrees. La DB no entra en git (snapshots binarios separados).
  6. Visual diff agentic — antes de cualquier push, Playwright captura N rutas clave before/after; el agente IA presenta cambios humano-aprobables en la UI. Esto es el diferencial.
  7. Aislamiento por defecto de side-effects locales — mu-plugin inyectado en cada local WP nukea: mail (a MailHog), cron (DISABLE_WP_CRON), Stripe live keys (regex en options), analytics, banner LOCAL no removible, auto-login token sin password.
  8. Reuso máximo del stack Remote-Pulse — mismo patrón Ed25519 + outbound polling + Postgres + FastAPI + Next.js + Tailscale Funnel + SOPS. Reusar no es reescribir.

Considered Options

1. Topología de despliegue del control plane

Opción Pros Contras Veredicto
LXC dedicado en pmx-50 con CP + Docker-in-LXC para los WPs locales 24/7, Tailscale Funnel siempre arriba, agentes polling sin interrupciones, Claude Code accede vía SSH/Tailscale como ya hace con todo el homelab, snapshot ZFS por LXC docker-in-LXC requiere nesting=1 en LXC config (ya validado para otros servicios), recursos compartidos con cluster Elegido
Mac local (Docker Desktop + CP) Dev experience nativa, sin SSH Solo on cuando el portátil está abierto; preview tunnels mueren al cerrar; drift detection diaria imposible; familia/cliente no puede pedir preview a las 23:00 ❌ Contradice driver #2 (onboarding) y feature preview pública
1 LXC por sitio cliente (full aislamiento ZFS) Blast radius mínimo, snapshot/restore por cliente con PBS, "explota un WP" no toca otros Overhead operacional (N LXCs, N IPs, N entradas Tailscale ACL); orquestación vía Proxmox API añade complejidad; recursos sub-utilizados (cada LXC reserva RAM aunque no se use) ⚠️ Considerado pero diferido a v2 — ver Open Questions
Kubernetes (k3s) con pod-por-sitio Estándar industria, ergonomía pods+services+ingress Overkill para 5-20 sitios single-operator; k3s en LXC = nesting compuesto frágil; aprendizaje y mantenimiento desproporcionados ❌ Sobreingeniería

Decisión host: LXC único nuevo en pmx-50 (provisional ID LXC 281, siguiente libre tras Remote-Pulse 280) con nesting=1, keyctl=1 para Docker. CP (FastAPI+Postgres+Next.js) corre como systemd services; los Dockers de los sitios cliente corren como docker compose con prefix wp-pulse-{slug} para namespacing. ZFS dataset rpool/wp-pulse con sub-datasets por sitio para snapshots granulares.

2. Stack del control plane

Opción Pros Contras Veredicto
FastAPI + Postgres 16 + TimescaleDB + Next.js (idéntico a Remote-Pulse) Reutilización máxima: mismos patrones Ed25519, mismas migraciones Alembic, mismo deploy Caddy+systemd, misma UI shell Next.js, mismas saved views, mismos webhook deliveries Stack ya conocido, sin ganancia de "novedad" (lo cual es una ventaja, no un contra) Elegido
FastAPI + SQLite + HTMX Más simple, menos piezas, sin Postgres Pierde reutilización RP; HTMX no encaja con visual diff side-by-side ni con saved views complejas; SQLite mal para concurrencia agentes+UI+jobs ❌ Ahorro ilusorio
Node/Bun fullstack Mismo lenguaje que ecosistema WP (PHP+JS) Cambio de stack respecto al resto del homelab, sin ganancia tangible ❌ Sin razón
Django + Postgres Admin builtin, ecosistema maduro WordPress-adjacent (ambos PHP→Python similar) Stack nuevo en el homelab, Admin de Django mal para visual diff y dashboards sparkline

Decisión stack: FastAPI 0.115+ + Python 3.12 + Postgres 16 + TimescaleDB + Next.js 15 + Tailwind. Idéntico a Remote-Pulse para que el operador pueda saltar entre repos sin context-switch.

3. Auth y transporte agente WP ↔ Control Plane

Opción Pros Contras Veredicto
Híbrido: Ed25519 outbound polling (default) + REST inbound con app password WP (opt-in por sitio) Polling funciona en hostings restrictivos (Kinsta, WP Engine, SiteGround) sin pedir cambios; inbound disponible para sitios "amigos" (VPS propios) donde un webhook tiene latencia <1s; firma Ed25519 idéntica a RP Dos paths a mantener; estado "pending command" requiere TTL+idempotencia Elegido
Solo polling Ed25519 Un único path, igual que RP Latencia mínima ~5-10s en operaciones interactivas (ej. wpdev shell con wp-cli) ⚠️ Subset de la opción elegida
Solo REST inbound Baja latencia No funciona en 60-70% de hostings managed; el sitio del cliente debería exponer endpoint público ❌ Falla driver #4
WebSocket persistente agente→CP Latencia <100ms Hostings PHP no mantienen procesos persistentes; requeriría agente sidecar Python/Node que la mayoría de shared hosting no permite ❌ Inviable en shared
WireGuard mesh por sitio Ultra-seguro Imposible instalar WireGuard en shared hosting; ergonomía nula

Decisión auth/transporte: Polling Ed25519 cada 15-30s (configurable) como default. Cada sitio tiene su propio keypair generado en el plugin la primera vez; pubkey registrada en CP durante onboarding. Fallback inbound: sitios marcados inbound_enabled=true exponen /wp-json/wp-pulse/v1/exec con verificación HMAC + nonce + app password — CP puede empujar comandos urgentes sin esperar al siguiente poll. Toggle por sitio en la UI.

4. Política de push a producción

Tipo de cambio Política Mecanismo
Código (theme, child theme, mu-plugins, plugins custom) Permitido con diff viewer + backup pre-push obligatorio + rollback 1-click Comando dedicado wpdev push code; el binario tiene este path
Plugins de terceros (instalar/actualizar/desinstalar) Permitido con dry-run en local primero + Playwright smoke tests + approval Comando wpdev push plugin <slug> <action>; CP corre matrix tests antes de aprobar
Base de datos (export local → import prod) Permitido con backup mysqldump obligatorio + dry-run con --show-changes + confirmación humana explícita por interfaz UI (no por CLI, para forzar el flujo de revisión visual) Comando wpdev push db --dry-run luego wpdev push db --confirm <token>. Token expira en 60s. No hay automatización
Uploads (push de archivos wp-content/uploads desde local) Solo pull, nunca push automático wpdev push uploads <file> existe para casos puntuales pero requiere flag --single-file y path explícito
wp_options críticas (siteurl, home, blogname, admin_email, active_plugins, template, stylesheet) Bloqueado en push de DB salvo opt-in explícito con --allow-unsafe-options por opción Lista hardcoded en el binario; CI valida que no se añadan por accidente

Decisión push: Permisivo con safety rails fortísimas. La diferencia con la postura purista ("DB nunca") es que el operador es senior y a veces necesita propagar un setting de Yoast o un cambio de WPML al cliente sin entrar a mano. El sistema fuerza review visual antes (dry-run + diff humano) y garantiza rollback después (backup pre-push automático con retención 30 días).

5. Estrategia de uploads (wp-content/uploads)

Opción Pros Contras Veredicto
Proxy nginx try_files → origen prod, cache local lazy Pull en segundos, local solo descarga lo que renderizas, transparente para WordPress (URLs idénticas) Requiere que prod sea accesible públicamente para los assets (lo normal); offline-friendly limitado Elegido (default)
Pull completo siempre Funciona offline, snapshot fiel 5-50 GB por sitio, 40+ min cada pull, llena disco LXC ❌ Ridículo por default
Pull completo opt-in por sitio + proxy default Flexibilidad para sitios pequeños o sin acceso público a assets Complejidad de tener dos paths Configurable en manifest (uploads_mode: proxy\|full)
Symlink a S3/R2 si el sitio usa offload Para los que ya tienen Offload Media Caso de uso minoritario; lo dejamos para v2 si aparece demanda ⚠️ Diferido

Decisión uploads: uploads_mode: proxy por default. nginx en el container local hace try_files $uri @origin; location @origin { proxy_pass https://cliente.com$request_uri; proxy_cache UPLOADS; }. Cache local con TTL infinito (assets no cambian de URL). Override por sitio para mode: full cuando el operador necesita modificar imágenes en local.

6. Plugins premium y licencias

Opción Pros Contras Veredicto
Mu-plugin "dev-mode" que mockea respuestas de licencia en local (Elementor, ACF, WPML, GF, Woo) — opt-in explícito en setup Funcionalidad completa en local sin gastar slot de licencia, sin invalidar prod Zona gris legal (mockear callback de validación); riesgo si el vendor cambia el contrato anti-piratería Elegido con opt-in explícito en onboarding y disclaimer
Copiar plugin pero sin activación Honesto legalmente Features clave bloqueadas en local (Elementor Pro widgets, WPML idioma secundario, ACF Pro flexible content), local no representa al real ❌ Falla driver #3 (fidelidad)
Skip plugins premium del pull Limpio Local roto: theme depende de ACF Pro y no carga → operador no puede trabajar
Pedir al cliente que aporte la dev license slot Más limpio Inviable cliente a cliente, mata onboarding fluido ⚠️ Caso futuro opcional

Decisión plugins premium: Mu-plugin wp-pulse-devmode.php que intercepta llamadas conocidas de licencia (filtros y hooks documentados de cada vendor) y devuelve valid=true en local. Lista de plugins soportados se mantiene en agent/devmode/registry.yaml con metadatos por vendor. Detección automática durante el pull: si active_plugins incluye un slug del registry, se ofrece activar dev-mode con confirmación explícita y warning legal. El mu-plugin nunca se incluye en push (filtrado por path en el push pipeline). Disclaimer: el operador asume responsabilidad legal del opt-in.

7. Git como source of truth del filesystem

Opción Pros Contras Veredicto
Repo git por sitio, git init automático en el primer pull Diffs naturales, branches por feature, agentes IA trabajan en worktrees aislados, history navegable, blame por línea, integración Claude Code trivial Tamaño repo crece con cada pull si no se gestiona; uploads NO entran en git (gitignore) Elegido
Solo manifest.json versionado Ligero Pierde diffs históricos, agentes IA no tienen contexto histórico, sin branches
Monorepo con todos los sitios Visión unificada Sitios pesan distinto, history mezclado, branches confusos cross-sitio

Decisión git: Cada sitio = repo git independiente en /srv/wp-pulse/sites/{slug}/. .gitignore por defecto: wp-content/uploads/, *.sql, *.sql.gz, wp-config.php (template separado con tokens), wp-content/cache/, wp-content/debug.log. Commits automáticos en cada pull (pull: snapshot 2026-05-29 14:30). Agente IA trabaja en branches agent/{feature-slug} que se mergean a main solo tras visual diff + approval. Remote opcional (GitHub privado por sitio) configurable post-MVP.

8. Onboarding: cómo entra un sitio nuevo al sistema

Opción Pros Contras Veredicto
Wizard que prueba ambos: REST app password primero, SSH como fallback, manual ZIP como último recurso Funciona en el 95%+ de hostings; el operador no necesita saber qué método aplica antes; un único flujo en la UI Más código en el wizard (4 paths) Elegido
Solo REST app password Rápido en hostings modernos Falla en hostings antiguos sin REST o con WAF que bloquea PUTs
Solo SSH Fiable cuando hay SSH El 50% de shared hosting no da SSH o lo da con jaula
Manual upload ZIP del plugin por el operador Funciona siempre Fricción alta; rompe la promesa de onboarding <5 min ⚠️ Fallback de último recurso

Decisión onboarding: Wizard wpdev onboard https://cliente.com interactivo. Pasos:

  1. Probe: curl https://cliente.com/wp-json/ → si responde, REST disponible.
  2. REST path: pedir admin user + app password (no la contraseña real; el operador la genera en wp-admin → Users → Profile → Application Passwords). El CP instala el plugin via POST /wp/v2/plugins.
  3. SSH path (fallback): pedir host + user + key path. Subir el plugin via SCP, activar via wp plugin activate wp-pulse-agent por SSH.
  4. Manual path (último recurso): generar ZIP firmado + instrucciones; el operador lo sube por cPanel/FTP y pega el enrollment_token que el plugin muestra al activarse.
  5. Verificación: el agente hace su primer poll, el CP lo registra, primer snapshot inicia automáticamente. Sitio aparece en la UI con badge "Initial sync in progress".

9. Integración con agente IA

Opción Pros Contras Veredicto
Claude Code CLI nativo vía SSH/Tailscale al LXC, trabajando en worktrees git por sitio Reusa workflow existente del operador, sin reinventar UI agentic, sin lock-in a un modelo concreto; Claude Code maneja Plan/Edit/Bash/Tests El operador debe iniciar la sesión manualmente (no hay botón "agentic" en la UI todavía) Elegido para v1
Botón "Agente" en la web UI que dispara Claude Code headless con prompt prefilled Más producto, más pulido Más código (orchestration headless, streaming logs a UI, manejar errores de modelo); reproducir lo que ya hace CC manualmente ⚠️ Diferido a v1.x
Integración multi-modelo (Claude + Codex + locales Ollama) Optionalidad Sobreingeniería para fase 1; cada modelo tiene quirks distintos

Decisión agente IA: Claude Code CLI usado directamente por el operador via SSH al LXC. Cada sitio tiene un WORKTREE.md autogenerado que el operador linka al iniciar CC, con: paths críticos, plugins activos, dev-mode warnings, comandos wp-cli útiles, URLs locales+prod, lista de cambios pendientes. La UI del CP muestra el estado del worktree (branch actual, diffs sin commitear) pero no orquesta la sesión IA en v1. Hook para v1.x: endpoint POST /sites/{slug}/agent/spawn que arranca CC headless con prompt — diseñado pero no implementado en v1.

10. Preview público del entorno local

Opción Pros Contras Veredicto
Tailscale Funnel por sitio Ya en uso (RP), URL .ts.net firmada, free tier suficiente, sin DNS por sitio URL no-vanity (no es *.cliente.com); algunos clientes pueden encontrar la URL Tailscale "rara" Elegido v1
Cloudflare Tunnel con subdominio temporal URL más normal (preview-clienteX.tunnel.monxas.casa) Otra integración (CF tokens), más DNS, mantenimiento ⚠️ v1.x opcional configurable
ngrok / similar Trivial Vendor de pago, URLs efímeras, no profesional
Solo localhost (sin tunnel) Cero exposición No puedes enseñar al cliente sin pedirle cita ❌ Falla feature clave

Decisión preview: Tailscale Funnel por sitio bajo demanda. Comando wpdev preview {slug} --hours 4 levanta un Funnel temporal apuntando al container nginx local; URL se copia al clipboard, expira automáticamente. Hook en UI: botón "Share preview" que devuelve URL + QR + expiración. Cloudflare Tunnel queda como feature v1.x con flag preview_provider: tailscale|cloudflare en config global.

11. Aislamiento entre sitios cliente

Opción Pros Contras Veredicto
1 LXC único con todos los docker compose adentro, prefijo de nombre por sitio Simple ops, 1 punto de backup PBS, recursos compartidos eficientemente, networking docker bridge por sitio Blast radius = todo el LXC; un sitio con bug puede saturar disco/RAM si no se ponen quotas Elegido v1
1 LXC por sitio Aislamiento total, ZFS snapshot por cliente Overhead de N LXCs, IPs, ACLs Tailscale, RAM reservada por LXC; orquestación cross-LXC ⚠️ v2 si la escala lo justifica
Pods k3s Estándar industria Overkill para 5-20 sitios single-op

Decisión aislamiento: LXC único wp-pulse-host con todos los compose dentro. Mitigaciones del blast radius:

  • Docker resource limits por compose (CPU 2 cores, RAM 1-2 GB, disk quota volumen).
  • Cada sitio en su propia bridge network Docker (wp-pulse-{slug}-net).
  • ZFS dataset por sitio (rpool/wp-pulse/sites/{slug}) → snapshot/restore granular sin afectar a los demás.
  • PBS backup del LXC entero + dumps SQL por sitio en /srv/wp-pulse/backups/{slug}/.

12. Nombre del proyecto

Decisión: WP-Pulse. Razones: consistencia con Remote-Pulse (suite Pulse), reutilización del patrón nominal hace que el operador y futuros usuarios vinculen mentalmente "Pulse = mi herramienta de agentes outbound + CP centralizado". Componentes: wp-pulse-server (CP, repo privado en homelab-infra/services/wp-pulse-server/), wp-pulse-agent (plugin WP, repo privado monxas/wp-pulse-agent con migración a FOSS condicional), wpdev (CLI local opcional, futuro).


Architecture

High-level

┌────────────────────────────────────────────────────────────────────────┐
│  Operador (Ramón) — Mac + Claude Code via SSH/Tailscale                │
└──────────────┬─────────────────────────────────────────────────────────┘
               │ SSH/Tailscale
┌────────────────────────────────────────────────────────────────────────┐
│  LXC 281 wp-pulse-host (pmx-50, nesting=1, Tailscale + Caddy)          │
│                                                                          │
│  ┌────────────────────────────────────────────────────────────────────┐│
│  │  wp-pulse-server (systemd)                                          ││
│  │  - FastAPI :8080  (REST + WS + polling endpoints)                   ││
│  │  - Next.js :3000  (web UI)                                          ││
│  │  - Postgres 16+TimescaleDB :5432                                    ││
│  │  - Redis :6379 (caches, job queue)                                  ││
│  │  - Worker (RQ): pulls, pushes, playwright, dumps                    ││
│  └────────────────────────────────────────────────────────────────────┘│
│                                                                          │
│  ┌────────────────────────────────────────────────────────────────────┐│
│  │  Docker daemon (en LXC)                                             ││
│  │  ┌───────────────────┐  ┌───────────────────┐  ┌─────────────────┐ ││
│  │  │ wp-pulse-cliente-a│  │ wp-pulse-cliente-b│  │  ...cliente-N   │ ││
│  │  │  - nginx          │  │                   │  │                 │ ││
│  │  │  - php-fpm 8.2    │  │                   │  │                 │ ││
│  │  │  - mariadb 10.6   │  │                   │  │                 │ ││
│  │  │  - mailhog        │  │                   │  │                 │ ││
│  │  └───────────────────┘  └───────────────────┘  └─────────────────┘ ││
│  └────────────────────────────────────────────────────────────────────┘│
│                                                                          │
│  /srv/wp-pulse/                                                          │
│    ├── sites/{slug}/  (git repos: wp-content, themes, plugins, manifest)│
│    ├── backups/{slug}/ (mysqldump + tar pre-push, retention 30d)        │
│    └── secrets/  (SOPS+age encrypted tokens per site)                    │
└──────────────┬─────────────────────────────────────────────────────────┘
               │ Tailscale (Funnel for previews) + HTTPS outbound (poll)
┌────────────────────────────────────────────────────────────────────────┐
│  N sitios cliente WordPress (hostings externos heterogéneos)           │
│                                                                          │
│  Plugin wp-pulse-agent instalado:                                        │
│    - Cron WP cada 30s: poll GET /sites/{site_id}/commands/pending       │
│    - Firma Ed25519 con keypair generado en activación                   │
│    - Ejecuta: snapshot, exec_wp_cli, file_diff, push_file               │
│    - Reporta: heartbeat, php_version, wp_version, plugin_list           │
│    - REST inbound opcional: /wp-json/wp-pulse/v1/exec (HMAC + nonce)    │
└────────────────────────────────────────────────────────────────────────┘

Data model (Postgres)

-- Sitios registrados
sites (
  id uuid PRIMARY KEY,
  slug text UNIQUE NOT NULL,         -- 'cliente-a'
  url text NOT NULL,                  -- 'https://cliente-a.com'
  pubkey_ed25519 bytea NOT NULL,      -- agente firma con privkey correspondiente
  inbound_enabled bool DEFAULT false,
  inbound_app_password_enc bytea,     -- SOPS-encrypted si inbound_enabled
  uploads_mode text DEFAULT 'proxy',  -- 'proxy' | 'full'
  devmode_plugins text[] DEFAULT '{}',-- ['elementor-pro', 'acf-pro']
  preview_provider text DEFAULT 'tailscale',
  created_at timestamptz,
  last_heartbeat_at timestamptz,
  archived_at timestamptz
);

-- Manifest por sitio (versionado)
site_manifests (
  id uuid PRIMARY KEY,
  site_id uuid REFERENCES sites,
  snapshot_at timestamptz NOT NULL,
  wp_version text,
  php_version text,
  php_extensions text[],
  php_ini jsonb,
  db_engine text, db_version text,
  multisite bool,
  active_theme text, active_theme_version text,
  plugins jsonb,                       -- [{slug, version, active, premium}]
  constants jsonb,                     -- wp-config.php
  options_snapshot jsonb,              -- whitelisted critical options
  uploads_size_bytes bigint,
  manifest_hash text
);

-- Snapshots (eventos de pull)
snapshots (
  id uuid PRIMARY KEY,
  site_id uuid REFERENCES sites,
  manifest_id uuid REFERENCES site_manifests,
  started_at timestamptz, completed_at timestamptz,
  git_commit_sha text,                 -- HEAD del repo local tras el pull
  db_dump_path text,                   -- /srv/wp-pulse/backups/{slug}/snap_xxx.sql.gz
  uploads_strategy text,
  bytes_transferred bigint,
  status text                          -- 'running' | 'completed' | 'failed'
);

-- Comandos pendientes para el polling
commands (
  id uuid PRIMARY KEY,
  site_id uuid REFERENCES sites,
  kind text,                           -- 'pull_db' | 'pull_files' | 'exec_wpcli' | 'push_file' | 'push_db'
  payload jsonb,
  status text,                         -- 'pending' | 'claimed' | 'completed' | 'failed' | 'expired'
  expires_at timestamptz,
  signed_by_server_at timestamptz,
  result jsonb,
  created_at timestamptz
);

-- Pushes (auditoría)
pushes (
  id uuid PRIMARY KEY,
  site_id uuid REFERENCES sites,
  kind text,                            -- 'code' | 'db' | 'plugin_install' | 'plugin_update'
  files_changed text[],
  diff_summary jsonb,
  backup_path text,                     -- backup pre-push obligatorio
  visual_diff_artifacts jsonb,          -- paths a screenshots before/after
  approved_by text,
  approved_at timestamptz,
  applied_at timestamptz,
  rolled_back_at timestamptz,
  status text
);

-- Drift (hypertable TimescaleDB)
SELECT create_hypertable('heartbeats', 'observed_at');
heartbeats (
  observed_at timestamptz,
  site_id uuid,
  php_version text,
  wp_version text,
  wp_core_update_available bool,
  plugin_updates_available int,
  php_error_count_24h int,
  uploads_size_bytes bigint,
  rtt_ms int
);

API surface (FastAPI)

# Server → Agente (polling)
GET    /sites/{site_id}/commands/pending     (agente, firma cada poll)
POST   /sites/{site_id}/commands/{cmd_id}/result  (agente reporta)
POST   /sites/{site_id}/heartbeat            (agente cada 5 min)

# Server inbound opcional
POST   /sites/{site_id}/exec                 (CP → agente cuando inbound_enabled)

# UI/CLI → Server
POST   /sites                                (onboard nuevo)
GET    /sites                                (listar)
GET    /sites/{slug}                         (detalle)
POST   /sites/{slug}/pull                    (disparar pull manual)
POST   /sites/{slug}/local/up                (levantar docker compose)
POST   /sites/{slug}/local/down              (parar)
POST   /sites/{slug}/preview                 (Tailscale Funnel temporal)
GET    /sites/{slug}/snapshots               (history)
POST   /sites/{slug}/restore/{snapshot_id}   (rollback local a snapshot)
POST   /sites/{slug}/push/code               (dry-run obligatorio primero)
POST   /sites/{slug}/push/db                 (dry-run + confirm token)
POST   /sites/{slug}/push/{push_id}/rollback
GET    /sites/{slug}/logs?stream=true        (SSE: debug.log + php-fpm + nginx)
POST   /sites/{slug}/wpcli                   (ejecutar wp-cli en local o prod)
POST   /sites/{slug}/visual-diff             (Playwright before/after)
GET    /fleet/drift                          (cross-site dashboard)
POST   /fleet/bulk-action                    (ej. "update Yoast en N sitios")

Plugin agente WP — estructura

wp-pulse-agent/
├── wp-pulse-agent.php                       (main, define constantes, activación)
├── includes/
│   ├── class-wp-pulse-poller.php            (cron WP cada 30s, polling con curl)
│   ├── class-wp-pulse-crypto.php            (Ed25519 firma con sodium o phpseclib)
│   ├── class-wp-pulse-executor.php          (dispatcher de commands)
│   ├── class-wp-pulse-inventory.php         (manifest builder)
│   ├── class-wp-pulse-snapshot.php          (DB dump, files tar streaming)
│   ├── class-wp-pulse-rest.php              (endpoint inbound opcional)
│   └── class-wp-pulse-logger.php            (log local + envío diferido al CP)
├── devmode/                                 (mu-plugin separable)
│   ├── wp-pulse-devmode.php
│   └── vendors/{elementor-pro,acf-pro,wpml,gravityforms,woo}.php
├── README.md
└── wp-pulse-agent.zip                       (artifact CI)

Phases

Cada fase tiene: deliverables, sub-agent breakdown estilo ADR-0008 (paralelo cuando posible), success criteria, exit criteria.

F0 — Foundation (semana 1)

Goal: esqueleto vivo del CP + plugin agente + auth Ed25519 + un sitio de prueba registrado.

Sub-agents (paralelos):

Sub-agent Tarea Output
F0-LXC Crear LXC 281 con nesting+keyctl, Docker daemon, Tailscale, Caddy reverse-proxy a :8080 LXC operacional, Tailscale magic-dns wp-pulse.tailnet.ts.net
F0-DB Postgres+TimescaleDB+Alembic, primera migración con todas las tablas del data model 001_initial_schema.py
F0-API FastAPI skeleton con auth Ed25519, endpoint /heartbeat, /commands/pending, /commands/result Server arranca, curl /health OK
F0-PLUGIN Plugin WP con activación que genera keypair, registra contra CP, primer heartbeat ZIP instalable, primer poll exitoso
F0-SECRETS SOPS+age para tokens por sitio, integración con homelab-infra age keypair existente secrets/sites/{slug}.yaml cifrado

Success criteria F0: instalo plugin en cliente-test.local (un WP de juguete en cualquier hosting), aparece en GET /sites, heartbeat cada 5min, agente firma con Ed25519, CP verifica firma.

F1 — Pull pipeline (semana 2-3)

Goal: wpdev pull cliente-a baja un sitio real y lo levanta en Docker funcional localmente.

Sub-agents:

Sub-agent Tarea
F1-MANIFEST Plugin: builder del manifest completo (wp_version, php_version, ini, extensions, plugins, theme, options whitelisted)
F1-DB-DUMP Plugin: mysqldump via WP DB_USER/DB_PASS streaming a CP (chunks signed)
F1-FILES Plugin: tar streaming de wp-content/{themes,plugins,mu-plugins} a CP
F1-COMPOSE-GEN CP: generador de docker-compose.yml según manifest (versión PHP, MySQL, ini values, extensiones)
F1-RESTORE CP: restore DB en container local, search-replace serialization-aware con wp-cli, fix permissions
F1-NGINX-PROXY CP: template nginx con try_files → @origin para uploads proxy mode
F1-MU-LOCAL CP: mu-plugin "wp-pulse-local-safety" inyectado en cada local con mail/cron/banner/auto-login
F1-HTTPS CP: mkcert por sitio, cert mounted en nginx, sitio local en https://{slug}.wp-pulse.local (resolv via /etc/hosts del LXC o caddy)
F1-GIT-INIT CP: git init automático tras restore, primer commit "snapshot YYYY-MM-DD HH:MM"
F1-CLI CLI wpdev con comandos: pull, up, down, status, open

Success criteria F1: wpdev pull cliente-real-X con un cliente real → 3-5 min después tengo https://cliente-real-x.wp-pulse.local funcional, login auto, sin enviar mail, banner LOCAL, uploads proxy a prod, git repo inicializado.

F2 — Web UI (semana 3-4, paralelo parcial con F1)

Goal: dashboard 1-to-N navegable.

Sub-agents:

Sub-agent Tarea
F2-UI-LIST Next.js: lista de sitios con heartbeat status, php/wp versions, plugins desactualizados, last pull
F2-UI-DETAIL Vista detalle por sitio: tabs Overview, Snapshots, Logs, Manifest, Plugins, Settings
F2-UI-ONBOARD Wizard onboarding (REST + SSH + manual paths)
F2-UI-LOGS-SSE Server-sent events para tail de debug.log + php-fpm + nginx (local + prod)
F2-UI-WPCLI Terminal embebido para wp-cli (xterm.js + ws) en local y prod
F2-AUTH PocketID OIDC integration (reusa setup homelab), single user con role admin
F2-SAVED-VIEWS Reuso del componente SavedViewsSwitcher de RP v1.0.13 para listas filtrables

Success criteria F2: abro https://wp-pulse.monxas.casa, veo todos los sitios, click en uno me da estado completo, ejecuto wp-cli desde el browser.

F3 — Push de código (semana 5)

Goal: push seguro de theme/child/mu-plugin/custom plugins con diff visual + backup + rollback.

Sub-agents:

Sub-agent Tarea
F3-DIFF CP: compute file-by-file diff entre HEAD local y filesystem remoto (agente lo computa con hashes + selectivo content)
F3-DIFF-UI Web: diff viewer con monaco editor, archivo por archivo, syntax highlighting
F3-BACKUP Plugin agente: tar de archivos afectados antes del push → enviado a CP /backups/{slug}/{push_id}/
F3-APPLY Plugin agente: aplica los archivos via wp-cli o filesystem direct write, con validate-permissions
F3-ROLLBACK Plugin agente: restore desde tar de backup; UI tiene botón rollback en histórico de pushes
F3-CI-CHECKS CP: pre-push hook que corre php -l en archivos modificados + checks de tamaño + scan de <?php exec injections
F3-APPROVAL UI: approval flow estilo RP (botón Approve + Telegram opt-in con quick approve link)

Success criteria F3: modifico functions.php en local, commit, wpdev push code, veo diff en UI, apruebo, push aplicado en 30s, backup automático, rollback disponible.

F4 — Agentic + Visual diff (semana 6-7)

Goal: Claude Code workflow + Playwright before/after.

Sub-agents:

Sub-agent Tarea
F4-WORKTREE-DOC CP: autogenerar WORKTREE.md por sitio con paths críticos, comandos útiles, dev-mode warnings
F4-PLAYWRIGHT Worker: Playwright service con headless chromium, capture N rutas configurables por sitio en before/after
F4-VISUAL-DIFF-UI Web: comparador side-by-side, slider, diff pixel-level con threshold configurable
F4-ROUTES-CONFIG UI: configurador de "rutas clave" por sitio (default: /, /sobre-nosotros, /servicios, /contacto, /shop si es Woo, /carrito)
F4-LIGHTHOUSE Worker: Lighthouse antes/después en rutas clave, delta de Performance/A11y/Best Practices
F4-AGENT-HOOK CP: endpoint hook POST /sites/{slug}/agent/spawn (esqueleto, no implementado todavía) + UI badge "Agent active" basada en presencia de agent/* branches

Success criteria F4: abro Claude Code SSH al LXC, cd /srv/wp-pulse/sites/cliente-a && claude, le pido "haz la home responsive", trabaja en branch agent/responsive-home, push code dispara Playwright visual diff automático, apruebo desde UI con confianza visual.

F5 — Drift dashboard + Update orchestrator + Bulk ops (semana 8)

Goal: visibilidad cross-sitio + automatización de updates.

Sub-agents:

Sub-agent Tarea
F5-DRIFT-COLLECT Agente: heartbeat enriquecido con wp_core_update, plugin_updates, php_error_count_24h
F5-DRIFT-UI Web: fleet view con sparklines por sitio (RTT, error count), badges de updates pendientes
F5-UPDATE-FLOW CP: workflow "actualizar plugin X en sitios N" → local update + Playwright smoke + diff + approval por sitio (o bulk approve)
F5-BULK-WPCLI UI: comando wp-cli broadcast contra N sitios con preview de cada output
F5-ALERTS Integración con healthchecks.io o webhook propio: alertas si sitio cae heartbeat >30 min, si aparecen PHP fatal errors

Success criteria F5: UI me dice "Yoast 21.5 → 21.6 disponible en 8 sitios", click en bulk update, sistema actualiza 1-a-1 en local, corre smoke tests, me pide aprobar visualmente cada uno, aplica a prod con backup.

F6 — DB push (semana 9, opcional según madurez)

Goal: push DB con safety rails extremas.

Sub-agents:

Sub-agent Tarea
F6-DB-DRYRUN CP: comparador local vs prod tabla por tabla, output "tablas que cambiarían, filas que cambiarían, opciones bloqueadas"
F6-DB-PROTECT CP: hardcoded list de opciones intocables (siteurl, home, blogname, admin_email, active_plugins, template, stylesheet) salvo --allow-unsafe-options
F6-DB-CONFIRM UI: dry-run output requiere click humano + token de 60s antes de proceder
F6-DB-BACKUP Agente: mysqldump completo pre-push con compresión → enviado a CP, retention 90 días
F6-DB-APPLY Agente: aplicar diff (no full restore) con transacción + savepoints; rollback automático si error

Success criteria F6: modifico opción de Yoast en local, wpdev push db --dry-run muestra "1 row in wp_options changed: wpseo_titles → '{...}'", apruebo en UI, push aplica, backup automático, rollback disponible 90 días.


Security

  • Ed25519 keypair por sitio generado en el plugin durante activación. Privkey nunca sale del WP (almacenada en wp_options cifrada con AUTH_KEY). Pubkey enviada al CP en enrollment.
  • CP firma comandos que envía al agente con su propia clave Ed25519; el agente verifica antes de ejecutar.
  • Tokens de enrollment son JWT efímeros (TTL 15 min) firmados por el CP, mostrados solo en la UI al iniciar onboarding manual.
  • SOPS+age para todos los secretos del CP (DB creds, OIDC client secret, tokens inbound app passwords por sitio). Age keypair compartido con homelab-infra existente.
  • Inbound endpoint del agente (cuando habilitado): HMAC-SHA256 + nonce + app password WP estándar como triple gate.
  • Network policies: sitios Docker locales en bridges separadas, sin acceso entre ellas. CP no expone Postgres/Redis fuera de localhost. Web UI solo via Caddy con OIDC PocketID o Tailscale.
  • Audit log firmado de cada push (kind, archivos, diff_hash, approver, timestamp) — append-only en Postgres + replicado a Loki.
  • Mu-plugin local safety: WP_ENVIRONMENT_TYPE=development, mail interceptado a MailHog, DISABLE_WP_CRON=true, Stripe live keys nulleados, banner CSS rojo no removible (servido por mu-plugin, intocable por theme).
  • Permissions Docker: UID/GID del host LXC mapeados a www-data del container para evitar drift de permisos (typical pain point).
  • Backup pre-push obligatorio: push falla si backup falla. No hay flag para saltárselo.

Consequences

Positive

  • Onboarding de sitio cliente <5 min vs ~45 min actual. ROI inmediato en cuanto haya 3+ sitios.
  • Producción intocable por accidente — el sistema fuerza review visual antes de mutar prod.
  • Claude Code productivo en sitios cliente sin reinventar UI agentic propia; reuso del workflow ya dominado.
  • Visual diff Playwright como diferencial vendible si algún día se abre a otros (FOSS futuro).
  • Drift dashboard convierte "no sé qué versión de PHP corre cliente X" en consulta de 1 segundo.
  • Reutilización RP acelera la primera GA en 30-40% (mismo patrón Ed25519, mismas migraciones Alembic, misma UI shell, mismo Caddy).
  • Snapshots por sitio en ZFS + PBS dan recuperación en minutos ante cualquier desastre local.

Negative

  • Footprint pmx-50 crece: 1 LXC nuevo + Docker daemon + N containers. Estimación inicial: ~4 GB RAM idle, ~10-20 GB con 5 sitios activos.
  • Mantenimiento del agente WP cross-hostings: algún hosting raro va a romper algo (curl outbound bloqueado, sodium PHP no instalado, wp-cron desactivado). Plan: lista de hostings testados + workarounds documentados.
  • Dev-mode plugins premium: zona gris legal asumida explícitamente por el operador. Si un vendor cambia su API anti-piratería, el dev-mode se rompe hasta que se actualiza el vendors/{slug}.php.
  • DB push autorizado abre la puerta a errores humanos catastróficos. Las safety rails (dry-run + confirm token + backup) bajan el riesgo mucho pero no a cero.
  • Single-user v1 significa que si esto se publica o se comparte con un colaborador, hay refactor (RBAC, multi-tenant) que no está en v1.

Risks & mitigations

Riesgo Probabilidad Impacto Mitigación
Hosting cliente bloquea curl outbound del agente Media Alta Detectar en onboarding probe, fallback a REST inbound (app password)
Plugin de licencia premium cambia API y dev-mode falla Media Media Tests de integración por vendor en CI; alertas si un sitio reporta "dev-mode failed"
Push DB rompe producción a pesar de safety rails Baja Catastrófica Backup obligatorio + rollback 1-click + retention 90 días; dry-run + confirm token + UI-only (no CLI)
Tailscale Funnel cap rate (free tier) Baja Media Previews on-demand con TTL; si excede, fallback a Cloudflare Tunnel configurable
LXC 281 satura disco con backups Media Media Retention automática (snapshots 30d, dumps 90d); ZFS compression; alertas a 80%
Agente WP filtra credenciales DB en logs Baja Alta Logger redacciona patrones DB_PASSWORD|API_KEY|SECRET antes de enviar

Open Questions

  1. Multi-tenant: ¿valdrá la pena prepararlo en v1 con hooks aunque no se use? Decisión actual: no — single-user, refactor cuando aparezca la necesidad real.
  2. 1 LXC por cliente vs LXC único: mantenida la decisión "único" pero el data model permite migrar a "uno por sitio" cambiando solo el orquestador. Reevaluar cuando >15 sitios o un cliente crítico requiera aislamiento HIPAA/PCI.
  3. Public FOSS del plugin agente: condicional a (a) plugin estable >3 meses, (b) tests cross-hostings, (c) decisión sobre devmode (no incluir en repo público probablemente).
  4. Integración agentic in-app (botón "Agent" en UI): diferido a v1.x; el endpoint POST /sites/{slug}/agent/spawn existe como hook pero no se implementa hasta validar que Claude Code SSH manual es subóptimo.
  5. WP multisite: v1 no lo soporta explícitamente. Detectado en manifest, el sitio se marca multisite=true y el pull falla con error claro. Soporte en v2.
  6. Headless WordPress / decoupled frontends (Faust, Frontity): fuera de scope v1. Sistema asume WP monolítico clásico.
  7. Hosting managed con restricciones extremas (WP Engine, Pantheon): ¿el agente puede instalarse? Investigar en F0/F1 con un sitio de prueba en cada uno.
  8. Costes Tailscale Funnel: verificar que el free tier soporta N previews simultáneos esperados (típicamente 1-3 a la vez).

Implementation Kickoff (F0 — next session)

Concrete tasks para arrancar F0 inmediatamente:

  1. Crear LXC 281 wp-pulse-host en pmx-50:
  2. Template: debian-12-standard
  3. Recursos iniciales: 4 vCPU, 4 GB RAM, 40 GB ZFS (rpool/wp-pulse)
  4. Features: nesting=1, keyctl=1
  5. Tailscale auth-key ephemeral via API
  6. Caddy reverse-proxy detrás de LXC 270/271 con wp-pulse.monxas.casa:8080 (API) y wp-pulse-app.monxas.casa:3000 (UI)

  7. Crear repos:

  8. homelab-infra/services/wp-pulse-server/ (privado, dentro del monorepo)
  9. monxas/wp-pulse-agent (privado, GitHub nuevo)
  10. Estructura inicial: services/wp-pulse-server/{api,worker,web,alembic,docker-compose.yml}

  11. Primer schema Alembic con las tablas del data model arriba.

  12. Plugin esqueleto WP:

  13. Activation hook: genera Ed25519 keypair (vía sodium), guarda en wp_options['wp_pulse_keypair'] cifrada con AUTH_KEY
  14. Cron WP wp_pulse_poll cada 30s
  15. Heartbeat con manifest mínimo (wp_version, php_version, hostname)
  16. REST inbound endpoint stub (devuelve 503 hasta F0 fin)

  17. CP endpoints mínimos:

  18. POST /sites/enroll (recibe pubkey + token + URL, valida, registra)
  19. GET /sites/{id}/commands/pending (firma verify, devuelve [] siempre en F0)
  20. POST /sites/{id}/heartbeat (firma verify, escribe en heartbeats)
  21. POST /sites/{id}/commands/{cmd_id}/result (stub)

  22. Test E2E F0: instalar plugin en un WP sandbox propio (puede ser un docker-compose suelto en mi Mac), enrollar, verificar primer heartbeat en Postgres, verificar firma Ed25519 round-trip.

Exit criteria F0: un SELECT * FROM heartbeats en Postgres con al menos 5 entradas reales del sitio de prueba, firmadas y verificadas, después de 25 minutos de plugin activo.


Implementation Notes (a rellenar durante ejecución)

(esta sección se completa al cerrar cada fase, estilo ADR-0008 "Implementation Notes")

F0 deploy — Accepted 2026-05-30

LXC 281 provisioned + first real WP enrolled + first auto-update through the custom updater chain. Plugin v0.2.5 GA.

Infra delta vs ADR plan: - IP changed from .197 (preliminary) → .211 (.197 conflicted with a sleeping Apple device that owned the ARP record for it). - Storage pool local-lvm doesn't exist on pmx-50 → switched to zfs-ha (the homelab convention; ADR mis-specified default). - ZFS dataset path: not rpool/wp-pulse (no rpool on pmx-50) — Proxmox-managed zfs-ha/subvol-281-disk-{0,1} instead. Auto-snap props set after mp0 creation.

Ansible role iterations (post-first-apply fixes, baked into commits): 1. SSH keys: pct --ssh-public-keys rejects comments/blanks → strip /root/.ssh/authorized_keys to a temp file before passing. 2. pct start returns 255 on "already running" (not 1) → updated failed_when. 3. wp_user_uid: 997 collided with systemd-timesync → moved to 990. 4. get_url lost the creates: arg in newer Ansible → removed (checksum makes it idempotent anyway). 5. SOPS binary checksum in the role was bogus → replaced with the real one. 6. sudo not in debian-12-standard template → added apt install. 7. Postgres DB creation: default template1 is SQL_ASCII → had to use template0 + lc_collate=C.UTF-8 + lc_ctype=C.UTF-8. 8. postgresql.conf template missing the Debian pg_wrapper paths (data_directory, hba_file, ident_file, etc.) → service refused to start. 9. locale en_US.UTF-8 not generated on slim template → switched to C.UTF-8. 10. flush_handlers needed between conf deploy + DB tasks so the timescaledb shared_preload_libraries was loaded before extension creation attempt. 11. Caddy snippet validation step relied on the main Caddyfile parsing (which has Cloudflare DNS plugin env-var deps) → switched to caddy fmt --check on the snippet alone.

Caddy / CF tunnel: - Snippet shipped with tls /etc/caddy/certs/monxas.casa.pem ... hardcoded cert paths that don't exist on LXC 270/271 → switched to Caddy automatic TLS (wildcard *.monxas.casa already in cache via global CF DNS-01 plugin). - Path matchers had forward_auth at top of site block which intercepted ALL requests including /api/v1/health → restructured to use explicit handle blocks: @health, @agent, @releases (public), default (PocketID gated). - CF Tunnel d3d370db-...e199d5: added 2 ingress routes via CF API (/accounts/{acct}/cfd_tunnel/{id}/configurations) for wp-pulse.monxas.casa and wp-pulse-app.monxas.casa → 192.168.0.250:443. - PocketID hostname: runbook said id.monxas.casa but the actual route is pocketid.monxas.casa; forward-auth endpoint is /forward-auth (not /api/oidc/forward-auth from the legacy RP runbook).

Plugin iterations (visible from git log monxas/wp-pulse-agent): - v0.1.0 → v0.1.1: enrollment UI was missing entirely; added the wpp_enroll admin-post handler + form. - v0.1.1 → v0.1.2: activation hook generated a local site_id which made the UI think the site was already enrolled and hide the form. Server is the authoritative source for site_id; only set after /enroll returns. - v0.1.2 → v0.2.0: first auto-update attempt via PUC (yahnis-elsts/ plugin-update-checker v5.7). Critical fixes from code review: Settings API ↔ WPP_Options collision, AUTH_KEY rotation detection, server signature timestamp drift check. - v0.2.0 → v0.2.1: critical /v1/sites/... → /api/v1/sites/... prefix fix (heartbeat/poll/logs/commands all silently 404'd in 0.2.0); WP 4.6 parse_url 2nd-arg compat (4.6 ignores it, returned full array, cast to string → "Array" → slug "array" in DB). - v0.2.1 → v0.2.2: duplicate "Inbound REST" section + /logs server stub. - v0.2.2 → v0.2.3: PUC removed entirely (kept fatal-erroring on WP 4.6 + PHP 7.4, couldn't reproduce locally). Replaced by ~200-LOC native WPP_Updater that hooks pre_set_site_transient_update_plugins + plugins_api directly. ZIP dropped from 512KB → 343KB. - v0.2.3 → v0.2.4: smart re-enroll on server (same slug+URL+new pubkey rotates pubkey on existing row instead of 409); "Latest available" row + colored badge in admin Connection card. - v0.2.4 → v0.2.5: fetch_metadata bypasses WPP_HTTP::parse_response (and thus the server-response-sig verification) because of a canonical mismatch we couldn't pin down between agent sign-on-wire and server request.url canonicalisation. Update metadata is non- authoritative anyway (ZIP comes from the trusted Caddy origin).

Server iterations (homelab-infra): - Custom /api/v1/agent-updates endpoint (PUC-compatible JSON format) serving from /srv/wp-pulse/releases/latest.yaml. - Public /agent-releases/{filename} via FastAPI FileResponse → Caddy reverse_proxy to LXC 281 (filesystem-mounted on LXC 281, not on the Caddy LXCs 270/271). Original spec had Caddy serve via file_server but the dir lives only on LXC 281. - Response-signing middleware removed (was causing the canonical mismatch above). When F3/F4 makes commands/pending payloads security-critical we'll re-add per-payload signing rather than blanket response signing. - Smart re-enrollment in routers/sites.py::enroll_site: same slug+URL with a new pubkey rotates on the existing row. - Stub POST /api/v1/sites/{site_id}/logs returns 204 so the agent log-buffer drain doesn't 404.

Production validation (2026-05-30): - LXC 281 @ 192.168.0.211, Tailscale not yet joined (ACL pending operator). - Postgres 16.14 + TimescaleDB 2.27.1, schema 001 applied, heartbeats hypertable. - Plugin v0.2.5 live on https://www.hotelaldamagolf.com (WP 4.6.17, PHP 7.4.33, SiteGround). Heartbeats every ~30s, polls every ~30s, logs flush on each cycle. - First auto-update verified end-to-end at ~01:54 CEST: 0.2.3 → 0.2.5 with one wp-admin click, WP downloaded wp-pulse-agent-0.2.5.zip from our /agent-releases/, installed and reactivated cron without any operator file ops.

F1 deploy

(pendiente)

F2 deploy

(pendiente)

F3 deploy

(pendiente)

F4 deploy

(pendiente)

F5 deploy

(pendiente)

F6 deploy

(pendiente)

Decisiones revisitadas durante implementación

(pendiente)

Actual implementation timeline

All phases below shipped in a single multi-sub-agent session on 2026-05-30 (except F0, which went GA earlier the same day with plugin v0.2.5).

Phase Date What shipped Notable design revisits
F0 Foundation 2026-05-30 (early) LXC 281, Postgres+TimescaleDB schema, Ed25519 signing, plugin v0.2.x enrollment, first auto-update Response-signing middleware removed (canonical mismatch on WP 4.6 + CF/Caddy round-trip); IP changed .197 → .211 (ARP conflict); per-payload signing deferred to F4+
F1 Pull pipeline 2026-05-30 wpdev pull end-to-end; agent dumps DB + tarballs wp-content; server restores into Docker stack; per-site preview URL set on pulls.local_url. Endpoints: POST /sites/{slug}/pull, GET /sites/{slug}/pulls, GET /sites/{slug}/local, POST /sites/{slug}/local/{up,down}. CLI: pull, pulls, up, down, open, info, status. Plugin v0.3.0 introduced streaming snapshots (DB + wp-content tar via subprocess to avoid set_time_limit on shared hosts) — v0.3.3 then backgrounded the tar via the system tar binary; native PHP archive driver kept crashing on SiteGround
F-preview Caddy + CF tunnel routing 2026-05-30 Per-site Caddy snippet pushed to LXC 270/271 via scp + caddy validate + systemctl reload; CF Tunnel ingress config PUT for {slug}-preview.monxas.casa → 192.168.0.250:443; PocketID forward_auth gating; WPP_PREVIEW_BYPASS_TOKEN query-string bypass for the visual-diff Playwright worker. Original ADR called the preview hostname *.local.monxas.casa and Tailscale Funnel was the v1 plan — switched to Cloudflare Tunnel + Caddy HA because: (a) we already have CF tunnel + wildcard cert; (b) Funnel rate caps would bite at 5+ sites; (c) reuses ADR-0007 stack. The original v1.2 roadmap item (CF Tunnel as alt provider) is therefore the default in v1.0
F3 Push pipeline 2026-05-30 wpdev diff / push / pushes / rollback / discard. Server worktree is a git repo, git diff HEAD → list of write/delete commands → signed → polled by agent. _run_push_worker polls Commands rows until terminal, then closes the push row. Endpoints: GET /sites/{slug}/diff, POST /sites/{slug}/push, GET /sites/{slug}/pushes, GET /sites/{slug}/pushes/{id}, POST /sites/{slug}/pushes/{id}/rollback, POST /sites/{slug}/discard. Plugin v0.3.1 added write_file / delete_file executor primitives with canonicalized path confinement to wp-content/{themes,plugins,mu-plugins}; v0.3.4 added rollback restore from backup tar. Approval flow simplified vs. ADR: single CLI confirm + click instead of Telegram quick-approve (deferred)
F4 Visual diff 2026-05-30 Playwright capture prod vs local for N routes per site, numpy pixel diff (replaced original Python loop after benchmarking: 30s → 7s), 3-up panel in web UI. Endpoint: POST /sites/{slug}/pulls/{pull_id}/recapture, POST /sites/{slug}/screenshots/cleanup. Original ADR mentioned Lighthouse delta; deferred (not implemented in v1.0) — visual-only is enough for the operator confidence signal. Agent-spawn hook (POST /agent/spawn) still deferred to v2.0
F-versions 2026-05-30 Immutable versioned snapshots of the local stack (DB + files + manifest). Auto-created on every pull (v_pull_*), manually via wpdev version save (v_save_*). Load tears down + restores; diff between two; delete refuses current. Endpoints: GET/POST/DELETE /sites/{slug}/versions[/...], POST /sites/{slug}/versions/{vid}/load, GET /sites/{slug}/versions/{v1}/diff/{v2}. CLI: version list/save/load/show/delete/diff/push. Not in original ADR — emerged in implementation as the cleanest way to support "load yesterday's snapshot for a customer demo" without re-pulling; supersedes the ADR's "snapshots" table for operator workflows
F-schedules (F6) 2026-05-30 Cron-driven auto-pulls with retention_days + max_versions policy. schedule_tick background task ticks every 60s, claims due rows, fires the same pull orchestrator as the manual path. Run history per schedule. Endpoints: GET/PUT/DELETE /sites/{slug}/schedule, POST /sites/{slug}/schedule/trigger, GET /sites/{slug}/schedule/runs. CLI: schedule show/set/enable/disable/trigger/runs/preview. F6 in the ADR was "DB push" — renumbered. DB push remains out of scope for v1.0 (the cost/benefit dropped once version load could replicate the same "carry a snapshot forward" outcome without ever mutating prod's DB)
F-fleet batch 2026-05-30 Multi-site batch ops with parallelism limit (default 3 concurrent). fleet_runner background task picks up FleetBatch rows and processes their items. Endpoints: POST /fleet/batch/{pull,recapture,discard}, GET /fleet/batches[/...], POST /fleet/batches/{id}/cancel. CLI: fleet pull/recapture/discard/batches/batch/cancel. Cancel is best-effort: in-flight items run to natural completion (cancelling a pull mid-stream would leave the agent in an undefined state)
F-dashboard 2026-05-30 Fleet health single-pane-of-glass + alert generation + optional Slack/Telegram delivery. alert_tick runs on the same 60s cadence as the scheduler. Verdicts: healthy / warning / critical / disabled. Endpoints: GET /dashboard/summary, GET /dashboard/alerts, POST /dashboard/alerts/{id}/acknowledge. CLI: dashboard [--watch], alerts list/ack. Slack/Telegram delivery is best-effort via api.utils.alert_delivery; we don't fail the tick if the webhook errors. Dashboard query plan is bounded ("single-digit fleet today; cache layer when it grows past a few hundred sites")

Métricas reales vs target

Metric Target Actual Notes
Onboarding por sitio ≤5 min ~3 min Tested with hotelaldamagolf.com — upload ZIP + activate + enroll token + first heartbeat
Pull inicial (sitio medio) ≤5 min ~2 min hotelaldamagolf.com: ~80 MB DB + ~200 MB themes/plugins after .gitignore filters
Push code (dry-run + apply) ≤90s ~15 s Verified push_id baae0730641d completed in 10s end-to-end
Visual diff (5 rutas) ≤2 min ~7 s After numpy-based pixel diff (was 30s with the Python pixel-by-pixel loop)
Detección drift cross-sitio ≤1 día ~60 s alert_generator + schedule_tick + _stuck_command_reaper all run on 60s ticks
Sitios soportados v1 5-15 2 enrolled, 1 producing real pulls/pushes hotelaldamagolf.com is the live customer; one staging site for testing
Tiempo de release-to-update n/a ~3 min scp ZIP + bump latest.yaml + operator clicks "Update now"
ZIP del plugin n/a 362 KB v0.3.5 with hardening pack; v0.2.5 was 343 KB

Plugin changelog (v0.2.5 → v0.3.5)

The agent plugin is at v0.3.5, shipped via the auto-update path on 2026-05-30. Brief per-version highlights:

  • v0.3.0 — Real streaming snapshots: DB dump + wp-content tar streamed in chunks back to the control plane (replaces v0.2.x pending stubs). Drops the per-cycle memory ceiling on shared hosts.
  • v0.3.1 — F3 executor primitives: write_file + delete_file commands with canonicalized path validation (themes/plugins/mu-plugins only, ..-traversal rejected). Enables push pipeline.
  • v0.3.2 — Diagnostics surfacing: agent-side debug log behind WPP_DEBUG_LOG constant + admin-screen surfacing of last poll, last error, last cron tick.
  • v0.3.3 — Backgrounded tar via system binary: shells out to the host tar rather than building the archive in PHP. Unblocks snapshot_files on SiteGround and other shared hosts that ship aggressive set_time_limit / PHP memory caps.
  • v0.3.4 — Rollback path: agent restores from the pre-push backup tarball on demand. Push backups live in wp-content/uploads/wp-pulse-backups/ (gitignored on the server side).
  • v0.3.5 — Security hardening pack (see next section): 17 P0 + 30+ P1 fixes across plugin, server, templates, web and CLI; bumped ZIP to 362 KB.

Security hardening (post-MVP code review wave)

After the initial all-phases-in-one-day push, four sequential review waves hardened the surface. Wave 3 was a regression review that caught 6 P0 blockers introduced by Wave 1/2 fixes.

Categories (representative items, not exhaustive — full list in the security commit body):

  • Server (P0): preview snippet tls_insecure_skip_verify removed where Caddy already trusts the upstream; WPP_PREVIEW_BYPASS_TOKEN start-up warning when unset (visual diff would otherwise hit the PocketID wall); stuck-command reaper for claimed rows with NULL claimed_at (SQL NULL comparison gotcha — orphans would have leaked past the reaper forever); push-worker session-factory leak fixed.
  • Server (P1): rate-limit on enroll endpoint; explicit tag:wp-pulse Tailscale ACL; admin token never logged (structlog redaction); fleet-batch cancel ignores already-terminal rows.
  • Plugin (P0): WPP_MAX_WRITE_BYTES cap on write_file payloads (default 32 MiB) to bound DOS-via-huge-write surface; wp_option_update allowlist enforced server-side too (defense in depth); path canonicalization rejects .. even after realpath(); encryption key derivation uses HKDF label wp-pulse-keypair-enc-v1 (versioned for future rotation).
  • Plugin (P1): sodium polyfill auto-detect; log redaction patterns expanded (Bearer ..., API_KEY, TOKEN, PASSWORD, DB_PASSWORD); inbound REST disabled by default + nonce store TTL'd.
  • Templates (P0/P1): mu-local-safety: Stripe key nullification runs once per request (not per filter call); MailHog SMTP forced even when plugins call PHPMailer directly; DISALLOW_FILE_MODS baked in; _WPCLI_IMAGE_BY_PHP matrix pins wp-cli images per PHP minor (no more "latest wp-cli on PHP 7.4" footguns); healthcheck branches per image (wordpress:*-fpm vs custom Dockerfile.php).
  • Web (P1): PocketID forward_auth on every preview hostname (no bypass without the env-var token); content-security-policy header set; saved-views switcher (reused from RP v1.0.13) instead of ad-hoc filters.
  • CLI (P1): wpdev won't leak admin token in --verbose HTTP logs; exit codes documented (0/1 for status, push, pull); --json works on every command including watch-mode dashboard one-shot.

Next iteration (v2.0 roadmap)

Original v1.x roadmap items, marked with their actual disposition:

  • v1.1 Integración agentic in-app (POST /sites/{slug}/agent/spawn) — still deferred. Claude Code CLI via SSH/Tailscale remains the workflow; no productive feedback that the in-app hook is needed yet.
  • v1.2 Cloudflare Tunnel como preview providershipped in F-preview (became the v1.0 default, not an alternative).
  • v1.3 Multi-environment per site — still roadmap.
  • v1.4 Bulk operations enriquecidaspartial: bulk pull / recapture / discard shipped in F-fleet; the "matrix of updates with predicted risk" UI is still roadmap.
  • v2.0 Multi-tenant + RBAC + 1 LXC per site optional + FOSS public agent — still roadmap; agent FOSS gate is the security review + 3-month stability soak.

New v2 candidates surfaced during implementation:

  • RQ worker for restart-survival. Today's BackgroundTask + _run_pull / _run_push_worker / fleet_runner all die if the FastAPI process restarts mid-pull. RQ + Redis (already running on LXC 281 per the architecture diagram) would survive systemd restarts.
  • Multi-environment per site (local + staging + prod) once the versions/schedules primitives prove out.
  • Batch policies (e.g. "every Monday 03:00 pull all sites, prune versions >90 days") layered over the schedule + fleet primitives.
  • RBAC for multi-operator — single PocketID role today; needs per-site grant + audit trail before opening to collaborators.

Final status: v1.0 shipped 2026-05-30 with plugin v0.3.5 GA on hotelaldamagolf.com. Nine phases across server + plugin + CLI + web UI, ~10k LOC, post-MVP security hardening of 17 P0 + 30+ P1 fixes across four review waves.