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 aCommand(kind="write_file", ...)row with the.htaccessDeny rule and commits it. The agent's command poller drainspendingcommands and executes them against the live customer site — there is no worktree write, nopending_changesrow, noapply-all-to-prodstep. 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 inpushes. - The
preview_diffreturned in the response IS just a string the UI shows for sanity-checking; the operator'sconfirm=trueis 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
filescategory ofpending_changes. The operator then promotes via the unifiedapply-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_actionsrow thatapply-all-to-prodreplays. Until the receiving mu-plugin exists, return 501 / hide the button.
Pending-changes coverage gaps¶
pending.py aggregator only surfaces categories files and quarantine — db category never landed¶
- File:
api/routers/pending.py:117,122,373-402,503-506 - Detail. The
categoryLiteral is hard-coded to["files", "quarantine"]. There is nodbbranch, no scan ofpending_db_changes, and no replay path inapply_all_to_prod. The prompt's premise ("W1.5 added it") is false on this tree — search forpending_db_changesinpending.pyreturns zero hits. - Downstream effect: every mutating call in
users.py,posts.py,wp_options.py,db_tools.pywrites a row topending_db_changes(good) — but the SiteHero badge never counts those rows andapply-all-to-prodnever 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: scanpending_db_changes WHERE site_id=? AND applied_at IS NULL, count by kind, and add a_push_dbstep toapply_all_to_prodthat walks the rows and replays via wp-cli against the live site. Mark rows asapplied_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-prodrunswp user reset-passwordagainst the live customer site for each selected user." This is aspirational —apply_all_to_prodinpending.pyhas no code that readspending_user_actions. Rows accumulate forever. - Recommendation. Either (a) wire
apply_all_to_prodto drainpending_user_actionsafter 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
cleanupkind, so the implementation useskind="option"and disambiguates inside theafterpayload. When the future replay path lands, anyone queryingpending_db_changesfor 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 noacknowledged_dry_runflag — the siblingsetup/dry-runexists but the backend won't refuse/setupwithout it. Admin-token gated, so low blast radius, but aconfirm=truetwo-step would match the security model the rest of the surface uses.POST /sites/{slug}/perf/lighthouse-rundefaultstarget="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-nowdeliberately does NOT log topending_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¶
- Block-merge: rewrite
security.pyH2 and H3 to land via the worktree /pending_db_changespath. 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. - Block-merge: add a
dbcategory topending.py(scanpending_db_changesunapplied rows + replay branch inapply_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. - Block-merge: drain
pending_user_actionsinapply_all_to_prod(or remove the queue button until wired). Same silent-drop bug as #2. - Soft fix: add
kind="cleanup"to thepending_db_logLiteral sodb_tools.py:651stops piggybacking on"option". - Soft fix: require an
acknowledged_dry_run=truebody flag onPOST /dns/setup.