Skip to content

R4 Preview-first contract audit — 2026-05-31

Scope: every mutating endpoint in services/wp-pulse-server/api/routers/ touched in the last ~3 days. Looking for endpoints that hit prod without an explicit, intent-bearing operator gesture, and for mutations that escape the unified pending-changes / apply-all-to-prod aggregator.

Summary

  • Endpoints reviewed: 43 mutating routes across 27 routers.
  • PREVIEW-ONLY: 32
  • EXPLICIT-PROD (intentional, well-gated): 8
  • IMPLICIT-PROD (likely bug class): 2 (security H2/H3 enqueue Commands straight at prod).
  • Pending-changes coverage gaps: 3 (DB mutations, password-reset queue, security hardening commands).

Implicit-prod findings (highest priority)

[CRITICAL] POST /sites/{slug}/login-failures/block enqueues a prod write directly

  • File: api/routers/security.py:280-335
  • Detail. When body.confirm=true, the endpoint constructs a Command(kind="write_file", ...) row with the .htaccess Deny rule and commits it. The agent's command poller drains pending commands and executes them against the live customer site — there is no worktree write, no pending_changes row, no apply-all-to-prod step. The endpoint summary says "preview-first .htaccess Deny rule" but the implementation is the opposite: a single API call writes to prod with no preview, no diff review, no revert anchor (pre_apply_version_id), and no audit trail in pushes.
  • The preview_diff returned in the response IS just a string the UI shows for sanity-checking; the operator's confirm=true is a free-text boolean, not an intent-bearing two-step.
  • Recommendation. Route the .htaccess append through the worktree (file write) and let the file land in the files category of pending_changes. The operator then promotes via the unified apply-all-to-prod. Same gate as every other file edit.

[CRITICAL] POST /sites/{slug}/2fa-enforce enqueues a prod command directly

  • File: api/routers/security.py:383-417
  • Detail. Identical anti-pattern: builds a Command(kind="two_fa_nudge", payload={user_ids: ...}) and commits — that's a direct prod queue (the kind is even hand-rolled and not part of the documented executor whitelist; the docstring says "the mu-plugin itself isn't implemented in this PR — the queue side is", which means an operator click ships a no-op or, if the mu-plugin lands later, a silent live-site change). No preview, no pending-changes row, no revert anchor.
  • Recommendation. Convert to a pending_db_changes/pending_user_actions row that apply-all-to-prod replays. Until the receiving mu-plugin exists, return 501 / hide the button.

Pending-changes coverage gaps

pending.py aggregator only surfaces categories files and quarantinedb category never landed

  • File: api/routers/pending.py:117,122,373-402,503-506
  • Detail. The category Literal is hard-coded to ["files", "quarantine"]. There is no db branch, no scan of pending_db_changes, and no replay path in apply_all_to_prod. The prompt's premise ("W1.5 added it") is false on this tree — search for pending_db_changes in pending.py returns zero hits.
  • Downstream effect: every mutating call in users.py, posts.py, wp_options.py, db_tools.py writes a row to pending_db_changes (good) — but the SiteHero badge never counts those rows and apply-all-to-prod never replays them. The operator changes the preview-stack user list, sees no pending-changes badge, hits Apply, and prod stays untouched. Silent drop on the floor.
  • Recommendation. Add a third category to pending.py: scan pending_db_changes WHERE site_id=? AND applied_at IS NULL, count by kind, and add a _push_db step to apply_all_to_prod that walks the rows and replays via wp-cli against the live site. Mark rows as applied_at=now() when the corresponding push completes.

pending_user_actions (bulk password reset) is also never consumed

  • File: api/routers/users.py:608-680
  • Detail. The docstring explicitly says "the next apply-all-to-prod runs wp user reset-password against the live customer site for each selected user." This is aspirational — apply_all_to_prod in pending.py has no code that reads pending_user_actions. Rows accumulate forever.
  • Recommendation. Either (a) wire apply_all_to_prod to drain pending_user_actions after the file/db pushes, or (b) move this behind a feature flag and label the UI control as "queue (not yet wired)" so the operator isn't lied to.

db_cleanup rows are logged with kind="option" as a workaround

  • File: api/routers/db_tools.py:646-660
  • Detail. The helper's Literal type doesn't have a cleanup kind, so the implementation uses kind="option" and disambiguates inside the after payload. When the future replay path lands, anyone querying pending_db_changes for actual option mutations will see false hits.
  • Recommendation. Add a kind="cleanup" value to the Literal/enum before the db-category replay ships.

Defensive observations (not bugs, but call out for the record)

  • POST /dns/setup (dns_setup.py:85) mutates Cloudflare DNS with no acknowledged_dry_run flag — the sibling setup/dry-run exists but the backend won't refuse /setup without it. Admin-token gated, so low blast radius, but a confirm=true two-step would match the security model the rest of the surface uses.
  • POST /sites/{slug}/perf/lighthouse-run defaults target="prod" (performance.py:181). This is read-only measurement (a Lighthouse fetch of the public URL), so the default is correct — flagging only because the static grep flagged it.
  • wp_cron.py /cron/{hook}/run-now deliberately does NOT log to pending_db_changes (per its module docstring). Intentional — but the operator's UI should not present it as a "queue for apply-to-prod" action.

Per-endpoint classification table

Endpoint Method Class Notes
/sites/{slug}/pending-changes GET (read) aggregator — covers files+quarantine only
/sites/{slug}/apply-all-to-prod POST EXPLICIT-PROD refuses on active bisect; gated by require_approval
/sites/{slug}/quarantine add/remove POST/DELETE EXPLICIT-PROD target="preview" default; target="prod" requires explicit query param
/sites/{slug}/quarantine/bisect/* POST PREVIEW-ONLY refuses target!="preview" (quarantine.py:543)
/{slug}/push POST EXPLICIT-PROD operator-invoked push of worktree diff
/{slug}/pushes/{id}/rollback POST EXPLICIT-PROD rollback of a previous prod push
/{slug}/revert-window POST EXPLICIT-PROD revert N-minute window
/{slug}/discard POST PREVIEW-ONLY git reset HEAD in worktree
/{slug}/plugins/{path}/action POST PREVIEW-ONLY wp-cli on local stack
/{slug}/plugins/bulk POST PREVIEW-ONLY bulk wp-cli on local stack
/{slug}/plugins/install/wp-org POST PREVIEW-ONLY install into local stack
/{slug}/plugins/install/zip POST PREVIEW-ONLY install into local stack
/{slug}/themes/install/zip POST PREVIEW-ONLY worktree extract
/{slug}/themes/install/wp-org POST PREVIEW-ONLY worktree extract
/{slug}/themes/{slug} DELETE PREVIEW-ONLY worktree dir delete
/{slug}/themes/{theme_slug}/preview-switch POST PREVIEW-ONLY local stack only
/{slug}/users create/edit/delete POST/PATCH/DELETE PREVIEW-ONLY* *logs pending_db_changes but never replayed
/{slug}/users/bulk-password-reset POST PREVIEW-ONLY* *logs pending_user_actions but never replayed
/{slug}/posts/{id} edit/delete PATCH/DELETE PREVIEW-ONLY* *logs pending_db_changes but never replayed
/{slug}/users/{id}/impersonate POST PREVIEW-ONLY mu-plugin refuses on prod
/{slug}/impersonate-consume POST PREVIEW-ONLY preview stack mu-plugin
/{slug}/wp-options/{name} PATCH PREVIEW-ONLY* *logs pending_db_changes but never replayed
/{slug}/wp-config PUT PREVIEW-ONLY worktree file write
/{slug}/htaccess PUT PREVIEW-ONLY worktree file write
/{slug}/cron/{hook}/run-now POST PREVIEW-ONLY preview stack only
/{slug}/cron/{hook} DELETE PREVIEW-ONLY preview stack only
/{slug}/db/cleanup POST PREVIEW-ONLY* *logs pending_db_changes but never replayed
/{slug}/db/query POST PREVIEW-ONLY read-only SQL
/sites/{slug}/integrity/recheck POST PREVIEW-ONLY observability
/sites/{slug}/login-failures/block POST IMPLICIT-PROD (BUG) enqueues write_file cmd directly at prod
/sites/{slug}/2fa-enforce POST IMPLICIT-PROD (BUG) enqueues unimplemented two_fa_nudge cmd at prod
/sites/{slug}/backup-drills/run POST PREVIEW-ONLY simulate flag default
/sites/{slug}/perf/object-cache/probe POST PREVIEW-ONLY enqueues redis_probe (observability)
/sites/{slug}/perf/lighthouse-run POST (read) URL fetch, no mutation
/sites/{slug}/scheduled-applies POST EXPLICIT-PROD operator schedules future apply-all
/sites/{slug}/scheduled-applies/{id} DELETE preview-side cancellation
/approvals/{token}/decide POST EXPLICIT-PROD token-gated public route; calls apply_all_to_prod
/sites/{slug}/require-approval PATCH preview-side toggles per-site approval gate
/sites/{slug}/branches create/switch/merge POST PREVIEW-ONLY worktree git ops
/import-zip POST PREVIEW-ONLY creates new site, no existing prod touched
/create-fresh POST PREVIEW-ONLY creates new local install
/dns/setup POST EXPLICIT-PROD (no confirm step) mutates CF; admin-token gated only
/dns/setup/dry-run POST preview-side renders plan
/sites/{slug}/reports/generate POST preview-side PDF generation artifact only
/sites/{slug}/reports/settings POST preview-side server config
/sites/{slug}/status-page POST preview-side server config
/sites/{slug}/feedback/{id}/resolve POST preview-side server state
/widget/feedback POST preview-side row insert; public but harmless
/sites/{slug}/rehearsals POST PREVIEW-ONLY separate stack
/rehearsals/{id}/promote POST PREVIEW-ONLY worktree copy, no push
/rehearsals/{id}/discard POST preview-side teardown
/{slug}/versions/{id}/preview POST PREVIEW-ONLY time-travel ephemeral stack
/version-previews/{id} DELETE preview-side teardown
/{slug}/versions/diff POST (read) diff job
/{slug}/media/optimize POST PREVIEW-ONLY worktree image rewrites
/{slug}/media/optimize/{id}/apply POST PREVIEW-ONLY worktree image rewrites
/fleet/search-replace/preview POST PREVIEW-ONLY wp-cli --dry-run
/fleet/search-replace/apply POST EXPLICIT-PROD target="preview" default; explicit target="prod" to reach prod, with canary + abort

* = the route writes to a pending_* table that is never consumed by apply-all-to-prod. Operator-visible button promises a replay that won't happen.

Recommendations roll-up

  1. Block-merge: rewrite security.py H2 and H3 to land via the worktree / pending_db_changes path. Today's behaviour violates the post-2026-05-31 contract the rest of the system advertises and reintroduces exactly the incident class ADR-0014 was meant to prevent.
  2. Block-merge: add a db category to pending.py (scan pending_db_changes unapplied rows + replay branch in apply_all_to_prod). Until that ships, the operator's mental model ("I edited a preview user, the badge will show it, I'll apply when ready") is broken — silent prod-divergence is guaranteed.
  3. Block-merge: drain pending_user_actions in apply_all_to_prod (or remove the queue button until wired). Same silent-drop bug as #2.
  4. Soft fix: add kind="cleanup" to the pending_db_log Literal so db_tools.py:651 stops piggybacking on "option".
  5. Soft fix: require an acknowledged_dry_run=true body flag on POST /dns/setup.