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
dbcategory in pending-changes;apply-all-to-proddoes NOT replaypending_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 exposestarget=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 restore → save |
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:
- Overview (
health) — legacyFleetDashboard, default - Activity
- Batches
- Schedules
- Health (
fleet_health) — new ADR-0013 F3 (version/SSL/DB) - Security (ADR-0017 §H)
- 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-notesanchor (when notes exist)#panel-quarantineanchor (when quarantined)Manage pluginslink →/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:
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) attop-0 - Inside: nav links, fleet-pending chip (
Uploadicon), ThemeToggle, OP avatar, Sign-out - Below:
JustAppliedToastmounted attop-20,z-40,right-4 - Globally mounted (from
app/layout.tsx):ShortcutsCheatsheet,CommandPalette(both render their own modals atz-[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=health ⇒ overview for one release for shareable-link backwards-compat. |
Appendix — file pointers¶
services/wp-pulse-server/api/routers/pending.py— unified applyservices/wp-pulse-server/api/utils/pending_db_log.py— DB writerservices/wp-pulse-server/api/models/pending_db_change.py— modelservices/wp-pulse-server/alembic/versions/022_wp_console_state.py— migrationservices/wp-pulse-server/api/routers/pushes.py:945-1028—/recent-pushesservices/wp-pulse-server/api/services/scheduled_apply_runner.py:200-208— J2 → apply pathservices/wp-pulse-server/api/routers/approvals.py:329-340— J3 → apply pathservices/wp-pulse-server/web/components/app/PendingChangesBadge.tsxservices/wp-pulse-server/web/components/app/JustAppliedToast.tsxservices/wp-pulse-server/web/components/app/CommandPalette.tsxservices/wp-pulse-server/web/components/app/FleetPageTabs.tsxservices/wp-pulse-server/web/components/app/SiteHero.tsxservices/wp-pulse-server/web/components/app/TopBar.tsxservices/wp-pulse-server/web/components/app/ActivityFeed.tsxservices/wp-pulse-server/web/app/sites/[slug]/page.tsxservices/wp-pulse-server/web/app/sites/[slug]/layout.tsxservices/wp-pulse-server/api/main.py:425-518— router mounting order