Skip to content

R6 Integration audit — 2026-05-31

Read-only review of cross-feature integration in services/wp-pulse-server/ after the 10-sub-agent ~32 kLOC parallel sprint (ADR-0014 / ADR-0016 / ADR-0017 / ADR-0018). Focus: do the slices speak to each other through the unified surfaces (PendingChangesBadge, JustAppliedToast, Command palette, fleet tabs, SiteHero, window events) the way the design called for?

Summary

  • Critical integration gaps: 2 (no db category in pending-changes; apply-all-to-prod does NOT replay pending_db_changes).
  • Coverage gaps: 9 (SiteHero links missing for 12 of 16 per-site surfaces; CommandPalette indexes 0 of 14 new pages; ActivityFeed ignores 6 new event sources).
  • Coherence issues: 4 (1 routing collision in /sites/_all-imports; search-replace still exposes target=prod; ShortcutsProvider scope; duplicate "Schedules" entry).

Pending-changes coverage

Feature Category written by Surfaced in /pending-changes GET? Replayed by apply-all-to-prod? Notes
Worktree file edits (manual / plugins / themes / media optimizer / S&R local apply) files (git status against worktree) YES YES (start_push) Includes wp-content/plugins/* and wp-content/themes/* since both install routes write into worktree_path(slug)/wp-content/....
Quarantine drift quarantine (diff sites.quarantined_plugins vs prod_quarantined_plugins) YES YES (_push_worktree_quarantine_to_prod) Quarantine mu-plugin path is explicitly excluded from the files category to avoid double counting.
User edits (W1.4) pending_db_changes table (kind=user) NO NO api/utils/pending_db_log.py writes the row, but pending.py only computes files + quarantine. Apply-all does NOT replay. Operator cannot promote user edits via the unified flow.
Post edits (W1.4) pending_db_changes (kind=post) NO NO Same as users. Preview DB diverges silently.
Option edits (W1.4 F) pending_db_changes (kind=option) via wp_options.py + db_tools.py NO NO Editing wp_options on the preview never lands on prod through this surface.
Rehearsal worktree separate rehearse_path() worktree NO (correct) NO (correct) Rehearse is a separate stack — should NOT bleed into the main badge.
Media optimizer worktree file writes YES (via files) YES Wired correctly.
Scheduled apply (J2) re-enters apply_all_to_prod n/a (re-uses same path) YES Reuses the unified verb.
Approval decide (J3) re-enters apply_all_to_prod after token decide n/a YES Reuses the unified verb.
WP-Cron run-now / delete-hook preview-only (NOT logged) NO (intentional) NO (intentional) Documented in wp_cron.py docstring. OK.

Verdict: the W1.5 pending_db_changes table exists, has a feeder helper, three writers (users / posts / options + db_tools), an Alembic migration (022) and an index on (site_id, applied_at) — but nothing reads it. The unified apply-to-prod modal silently undercounts. This is the single most load-bearing gap in the audit.


JustAppliedToast trigger coverage

Trigger path Push DONE recorded in pushes table? Visible to toast? Notes
PendingChangesModal → apply yes YES (event + poll) wpp:push-completed dispatched on success (PendingChangesModal.tsx:188).
wpdev push CLI yes (push worker writes completed_at) YES (poll only) 20 s poll window — acceptable.
promote-to-prod quarantine direct call yes YES (poll) Same as above.
Approval decide (J3) → apply yes YES (poll) Goes through apply_all_to_prod.
Scheduled apply runner (J2) yes YES (poll) Same.
Fleet S&R apply (F6 canary loop) yes (per-site push_id in ApplyPerSiteRow) YES (poll) recent-pushes joins on PushV2 regardless of trigger.
Rollback push (RevertModal) yes, but is_rollback true NO (filtered) Correct — avoids "you reverted, undo?" loop.
Rehearsal promote uses separate worktree → main push pipeline YES (poll) Should fire — rehearse promotes via push.
Time-window revert yes (is_rollback heuristic from log_tail) NO (filtered) Correct.
DB-only mutations (user/post/option edits) NO push row created NO Direct consequence of the pending-changes gap above. The operator gets no "Just applied — Undo" affordance for any DB edit.

Verdict: the toast covers every code path that flows through PushV2. The blind spot is anything that mutates only the DB (W1.4 / W1.5 writers). Today those edits live in pending_db_changes and never reach PushV2, so the toast is structurally unable to surface them. Same root cause as gap #1.


Window event map

Event Dispatchers Listeners Status
wpp:open-palette hooks/useKeyboardShortcuts.ts:111 (⌘K) CommandPalette.tsx:125 OK
wpp:close-palette (none found in components/ or hooks/) CommandPalette.tsx:126 Dead listener — never dispatched. Either drop the listener or wire a "force close" path.
wpp:show-shortcuts useKeyboardShortcuts.ts:79 (?) ShortcutsCheatsheet.tsx:42 OK
wpp:open-pending-modal QuarantineModal.tsx:228, QuarantineBisect.tsx:476 PendingChangesBadge.tsx:116 OK
wpp:push-completed PendingChangesModal.tsx:188 JustAppliedToast.tsx:153 OK (only one dispatcher — every other path relies on recent-pushes poll, which is documented).
wpp:open-panel ActionSidebar.tsx:62-66 (openPanel() helper); RestoreAliasClient.tsx re-broadcasts restoresave CollapsiblePanel (via useOpenPanel hook in ActionSidebar.tsx:367), RestoreAliasClient.tsx OK

Coherent overall. One dead listener (wpp:close-palette).


Command palette index

Static nav entries in CommandPalette.tsx (lines 425-476):

  • Home
  • Fleet
  • Onboard new site
  • Settings
  • Schedules (deep-link to /fleet?tab=schedules)
  • Activity (deep-link to /fleet?tab=activity)
  • Mass search-replace → /fleet/tools/search-replace
  • Time-travel previews → /previews
  • Per-site Rehearse upgrade (context-aware, only when a slug is in the path)

Missing static entries for new per-site pages (operator must navigate via SiteHero / SiteTabNav, palette doesn't help):

  • /sites/[slug]/plugins
  • /sites/[slug]/themes
  • /sites/[slug]/users
  • /sites/[slug]/posts
  • /sites/[slug]/options
  • /sites/[slug]/config
  • /sites/[slug]/db
  • /sites/[slug]/security
  • /sites/[slug]/performance
  • /sites/[slug]/staging
  • /sites/[slug]/approvals
  • /sites/[slug]/reports
  • /sites/[slug]/feedback
  • /sites/[slug]/status-page

Also missing fleet tab deep-links:

  • Fleet → Batches (?tab=batches)
  • Fleet → Health (?tab=fleet_health)
  • Fleet → Security (?tab=security)
  • Fleet → Performance (?tab=performance)

Verdict: of the 14 per-site routes shipped in the sprint, 0 are indexed. The palette is essentially frozen at the pre-sprint vocab. The fix is mechanical (add Command.Items using the already-loaded siteSlug for context-aware fanout — same shape as the existing Rehearse upgrade for {slug} entry at lines 200-205).


Fleet tabs

Definition: FleetPageTabs.tsx:52-66. Order rendered:

  1. Overview (health) — legacy FleetDashboard, default
  2. Activity
  3. Batches
  4. Schedules
  5. Health (fleet_health) — new ADR-0013 F3 (version/SSL/DB)
  6. Security (ADR-0017 §H)
  7. Performance (ADR-0017 §I)

URL sync to ?tab=X works for all 7 (onSelect in FleetPageTabs.tsx:101-111).

Issues: - Name collision risk: tab IDs are health (Overview) and fleet_health (the real Health tab). The label Overview saves the operator visually, but ?tab=health legitimately means "Overview" (the legacy default) and ?tab=fleet_health means "Health (new)". This is confusingly named in the code; consider renaming the default tab ID to overview and freeing up health for the new tab (URL shim accepted). The comment at line 53-55 acknowledges this. - The TopBar at TopBar.tsx:147 deep-links to /fleet?tab=fleet_health&filter=pending — correctly using the new ID. CommandPalette at line 460 deep-links to ?tab=schedules. No duplicate paths; no overlap in rendered components.


SiteHero affordances

Discovery points exposed by SiteHero.tsx:

  • Pending-changes badge → opens unified apply modal
  • Time-window revert button (when applicable)
  • StartSession primary CTA
  • Open prod (site.url)
  • Open preview (siteLocalPreviewUrl)
  • Settings gear → /sites/{slug}/settings
  • #panel-notes anchor (when notes exist)
  • #panel-quarantine anchor (when quarantined)
  • Manage plugins link → /sites/{slug}/plugins

Missing per-site routes (no link from hero or anywhere in the single-page detail layout — operator must type the URL or use search):

Route ADR Discoverability
/sites/[slug]/themes ADR-0014 §B none from hero (no link, no palette entry)
/sites/[slug]/users ADR-0016 §E none
/sites/[slug]/posts ADR-0016 §E none
/sites/[slug]/options ADR-0016 §F none
/sites/[slug]/config ADR-0016 §F none
/sites/[slug]/db ADR-0016 §G none
/sites/[slug]/security ADR-0017 §H none
/sites/[slug]/performance ADR-0017 §I none
/sites/[slug]/staging ADR-0018 §J branches none
/sites/[slug]/approvals ADR-0018 §J3 none
/sites/[slug]/reports ADR-0018 §M none
/sites/[slug]/feedback ADR-0018 §M none
/sites/[slug]/status-page ADR-0018 §M none
/sites/[slug]/rehearse ADR-0013 §F7 only via palette context entry

Verdict: SiteHero leaks 12 of 16 surfaces. The single-page detail layout was a deliberate ADR-0012 simplification, but the sub-agent sprint pushed past it without re-introducing tab nav. The SiteTabNav component exists but is no longer mounted (see web/app/sites/[slug]/layout.tsx:6-19 — explicit decommission comment). This is the highest-volume coverage gap.

Recommendation: - Re-introduce a compact second row of icon links in SiteHero (under the activity strip) — one icon per surface, grouped by domain (Edit: plugins/themes/users/posts/options/config; Inspect: db/security/performance; Workflow: staging/approvals/rehearse; Outputs: reports/feedback/status-page). Roughly 14 icons fit in a single 32px row. - OR re-mount SiteTabNav from layout.tsx with a 16-tab definition. - AT MINIMUM: add the missing 14 entries to CommandPalette so the palette becomes a navigation safety net while a richer SiteHero ships.


Activity feed coverage

Defined event types in ActivityFeed.tsx:35-42:

pull | push | save | load | schedule_run | alert | visual_diff

Missing event sources from new features:

Source ADR Should surface? Suggested type
Bisect started / step / completed ADR-0013 F1.4 yes bisect
Mass search-replace completed ADR-0013 F6 yes search_replace or alert
Lighthouse run done ADR-0017 §I4 yes performance
File-integrity tamper detection ADR-0017 §H1 yes — high priority alert (existing)
Login-failure burst ADR-0017 §H2 yes alert
2FA-audit findings ADR-0017 §H3 yes alert
Backup-drill outcome (monthly) ADR-0017 §H4 yes alert (with subtype)
Approval decide (J3) ADR-0018 §J3 yes approval
Scheduled apply fire ADR-0018 §J2 partially via push row OK as-is
Rehearsal session ADR-0013 §F7 yes rehearsal

The alert channel is a usable catch-all for the security/perf rows today (with sub carrying the detail), but bisect / S&R / rehearsal have no fit. Tracking issue: the merge loop in web/app/sites/[slug]/page.tsx:65-115 (mergeActivityEvents) only joins pulls/pushes/versions/scheduleRuns — six new feature streams are invisible to the operator.


Routing collisions

Path A (registered first) Path B Method Verdict
GET /api/v1/sites/{slug} (sites.router) GET /api/v1/sites/_all-imports (imports.router) GET COLLISION. Sites router is registered first (api/main.py:432); imports.router at :502. Starlette matches /{slug} greedily for a single segment → get_site(slug="_all-imports") runs, returns 404. Comment at imports.py:215-218 claims the underscore prefix prevents collision; it does not when sites is registered first.
POST /api/v1/sites/import-zip POST not on sites POST OK (sites has no POST /{slug}).
GET /api/v1/sites/imports/{job_id} n/a GET OK — two segments, /{slug} matches one.
GET /api/v1/sites/fresh-installs/_options n/a GET OK — two segments.
POST /api/v1/sites/create-fresh n/a POST OK — no POST /{slug}.
GET /api/v1/sites/fresh-installs/{job_id} n/a GET OK — two segments.

Recommendation: rename /api/v1/sites/_all-imports to /api/v1/sites/imports/_all (two segments, no collision possible) OR register imports.router BEFORE sites.router in api/main.py.


TopBar real-estate

Mounted from web/components/app/TopBar.tsx:

  • Sticky header (z-30) at top-0
  • Inside: nav links, fleet-pending chip (Upload icon), ThemeToggle, OP avatar, Sign-out
  • Below: JustAppliedToast mounted at top-20, z-40, right-4
  • Globally mounted (from app/layout.tsx): ShortcutsCheatsheet, CommandPalette (both render their own modals at z-[60])
  • Per-site: PendingChangesBadge (inside SiteHero, no fixed positioning), TimeWindowRevertButton (same)

z-index ladder: 30 (top bar) → 40 (toast) → 60 (modals). No collisions. The toast sits in the empty area to the right of the TopBar at top-20 which means it floats just below the bar (TopBar is ~h-14 plus mobile nav row). On mobile, the bottom-nav is at the bottom of the viewport; no overlap with the right-top toast region.

One nit: on a long-running undo window (10 min) the toast covers any right-aligned action chip that might appear in a sub-page header. Not currently a problem (no other sticky right-top elements), but worth noting for future placements.


Findings

# Severity Location Detail Recommendation
1 CRITICAL api/routers/pending.py:99-105, :393-402, :418-638 pending_db_changes table is written by W1.4/W1.5 (users / posts / options / db_tools) but never read. The /pending-changes endpoint exposes only files + quarantine. apply-all-to-prod does not replay DB mutations. Operator-visible: editing a user's email on preview never lands on prod through the unified flow. Add a db category to PendingCategories. Aggregate unapplied rows by kind in the GET. In apply_all_to_prod, after the push runs, replay each row via a signed agent command (insert/update/delete on the live DB) and set applied_at + applied_push_id. The "before" image is already captured for revert symmetry.
2 CRITICAL JustAppliedToast.tsx (consequence of #1) DB-only edits create no PushV2 row → no completed_at → toast never fires → no "Undo" affordance. Either (a) make the apply path always create a push row (even a synthetic "db-only" one) so the toast picks it up, or (b) extend recent-pushes to UNION recently-applied pending_db_changes rows. (a) is cleaner.
3 HIGH web/components/app/SiteHero.tsx (all rows) 12 of 16 per-site surfaces (themes, users, posts, options, config, db, security, performance, staging, approvals, reports, feedback, status-page, rehearse) have zero discovery affordance from the hero. Layout intentionally removed tab nav but never replaced it. Re-mount SiteTabNav from layout.tsx with the full 16-surface definition, OR add a compact icon-link row to SiteHero (one icon per surface, grouped).
4 HIGH web/components/app/CommandPalette.tsx:425-476 Static nav indexes 0 of 14 new per-site routes and 4 of 7 fleet tabs. ⌘K is the navigation safety net while #3 ships; today it has the same gap. Add 14 context-aware entries (mirroring the Rehearse upgrade for {slug} pattern at lines 200-205) and 4 fleet-tab deep-links. ~50 lines, mechanical.
5 HIGH api/main.py:432 vs :502 GET /api/v1/sites/_all-imports is shadowed by GET /api/v1/sites/{slug}. The "underscore prefix" comment at imports.py:215-218 is wrong (collision IS real because sites.router registers first). Rename to /api/v1/sites/imports/_all (two segments) OR move app.include_router(migration_imports.router) ABOVE app.include_router(sites.router).
6 MEDIUM web/components/app/ActivityFeed.tsx:35-42 + web/app/sites/[slug]/page.tsx:65-115 Six new feature streams (bisect, mass S&R, Lighthouse runs, integrity tamper, login-failure bursts, 2FA findings, backup drills, approvals, rehearsals) emit no ActivityEvent. The feed is frozen at the ADR-0012 vocab. Extend ActivityEventType with bisect | search_replace | performance | approval | rehearsal and add the merge inputs to mergeActivityEvents. Quick win: route security-stream events through the existing alert type with sub carrying the detail (no new server work).
7 MEDIUM api/routers/fleet_search_replace.py:69, :119 F6 mass S&R still exposes target: Literal["preview", "prod"]. ADR-0013 unified design says per-feature target=prod toggles were stripped. Drop the target="prod" branch; operators promote via the unified apply-all-to-prod after the local apply lands files in the worktree. Backward-compat: accept the param, ignore prod, log a deprecation.
8 LOW web/components/app/CommandPalette.tsx:126 wpp:close-palette listener with no dispatcher anywhere in components/ or hooks/. Drop the listener OR wire it from places that want to force-close (e.g. after a successful CLI run that pushes the operator to a new page).
9 LOW web/components/app/FleetPageTabs.tsx:52-66 Tab IDs health (Overview) and fleet_health (Health) are confusing; comment at :53-55 acknowledges. URL ?tab=health doesn't mean the Health tab. Rename internal IDs to overview + health. Accept legacy ?tab=healthoverview for one release for shareable-link backwards-compat.

Appendix — file pointers

  • services/wp-pulse-server/api/routers/pending.py — unified apply
  • services/wp-pulse-server/api/utils/pending_db_log.py — DB writer
  • services/wp-pulse-server/api/models/pending_db_change.py — model
  • services/wp-pulse-server/alembic/versions/022_wp_console_state.py — migration
  • services/wp-pulse-server/api/routers/pushes.py:945-1028/recent-pushes
  • services/wp-pulse-server/api/services/scheduled_apply_runner.py:200-208 — J2 → apply path
  • services/wp-pulse-server/api/routers/approvals.py:329-340 — J3 → apply path
  • services/wp-pulse-server/web/components/app/PendingChangesBadge.tsx
  • services/wp-pulse-server/web/components/app/JustAppliedToast.tsx
  • services/wp-pulse-server/web/components/app/CommandPalette.tsx
  • services/wp-pulse-server/web/components/app/FleetPageTabs.tsx
  • services/wp-pulse-server/web/components/app/SiteHero.tsx
  • services/wp-pulse-server/web/components/app/TopBar.tsx
  • services/wp-pulse-server/web/components/app/ActivityFeed.tsx
  • services/wp-pulse-server/web/app/sites/[slug]/page.tsx
  • services/wp-pulse-server/web/app/sites/[slug]/layout.tsx
  • services/wp-pulse-server/api/main.py:425-518 — router mounting order