NV oOS Console SPA ↔ REST Contract Audit
Playbook for keeping the console SPA clients (addons/pro/assets/spa-v2/src/api/*.ts)
in contract parity with the WordPress REST controllers they consume. Canonized
from the 2026-10-21 audit (PR #7166) that found nine mismatches, including
the assistants edit form being completely broken (no GET/PUT single route;
the form read fields the directory never returned).
When to use this skill
- A console page renders blank/wrong/empty fields (the classic symptom)
- Adding or porting a console surface (Proposal 084) — audit before shipping
- Asked to "audit the REST contracts", "check for mismatches", or "continue
the contract parity pass"
- Reviewing a new
api/*.ts client against its controller
The bug classes (all real, from the 2026-10-21 audit)
- Phantom fields — client reads what the server never returns.
assistants.ts read system_prompt + capabilities from the directory
summary, which only has tools/description. Symptom: form fields always
empty.
- Ignored params — client sends what the server ignores. The assistant
form posted
capabilities; the create handler accepts tools.
- Missing routes/methods — client called
GET /assistants/{id} /
PUT /assistants/{id} / GET /slash-commands/list — none registered.
Symptom: 404 toast on save.
- Envelope mismatch — create returns the directory summary directly
(201) but the client unwrapped
{success,data} → {}. Also: bare-array
list responses vs {items} envelopes vs {success,data} wrappers — all
three exist across the codebase; check each.
- Field-name drift — chat-channels client typed
message/sender;
server emits content/contact_name. The vitest contract test baked in
the phantom fields, so it passed while the UI rendered blank.
- Settings gates —
rest_enable_assistant_create (default off) 403s
the console "Add Assistant" until enabled in Settings → NV oOS →
Authentication. Always check the permission callback for settings gates.
- Query-string typing —
filters sent as a JSON query string vs server
arg type => 'object' → WP 400 rest_invalid_type (sanitize runs before
validate; without a sanitize_callback a string never passes object
validation).
- Registration-context gating — the controller exists and is
contract-clean, but its
::init() is only called from an admin-gated
path, so the routes never register on front-end REST requests (the Pro
SPA renders on a front-end page; is_admin() is false there). Symptom:
the WHOLE route family 404s with "No route was found matching the URL and
request method" while other mcp-ai-pro/v1 routes work — the 2026-10-21
post-audit pass found the six Proposal 084 console bridges (ops overview,
workflow-presets, workflow-builder node exec, records, dashboards,
media-cc) registered inside wp_mcp_ai_pro_load_admin_sections(), which
only runs under the admin_sections module's context => 'admin' gate.
Fix: boot REST controllers from a context-free registry module (they now
live in rest_console_bridges) — each controller is idempotent and only
hooks rest_api_init, so per-request booting is safe. Regression test:
tests/rest-api/test-rest-console-bridges.php boots the registry and
asserts the routes exist after a fresh rest_api_init — the per-controller
tests masked this bug with a manual-registration fallback, so pin the real
boot path, not the class in isolation.
- **Route collision — two controllers register the same namespace + route
- method; the last registrant silently wins.** WP REST stores one handler
per route+method (
$routes[$route][$method] = $endpoint — a later
register_rest_route() REPLACES the earlier one), so whichever controller
registers last on rest_api_init owns the route. Real case (2026-10-22):
the scheduled-result controller's picker and the schedule CRUD controller
both registered GET /mcp-ai-pro/v1/schedules — the picker returns a bare
[{ id, name, … }] array while the console + SA SPA expect the CRUD
{ ok, schedules, total } envelope. Whichever won, the other surface
silently broke (schedules list empty/blank while presets — a different
route — kept working). Diagnosis: grep the REST tree for duplicate route
strings (grep -rn "'/schedules'" addons/pro/includes/rest/ includes/),
check the module-registry boot order (resolve_order() preserves insertion
order for dep-free modules — later modules overwrite earlier ones), and
inspect rest_get_server()->get_routes() for the surviving handler. Fix:
move the lesser-consumed handler to its own literal sub-path (picker →
/schedules/selectable, its block-editor caller follows) so both shapes
always resolve; pin with a regression test that registers the conflicting
controller in the losing order (require the file after the other
controller registered) and asserts the surviving envelope
(addons/pro/tests/test-pro-schedule-result-rest.php).
- Empty-then-loaded flashes — pages seed
useState([])/null and render
the empty state before the fetch resolves. Symptom: "shows empty for a
sec then loads in". Fix discipline: keep a loading flag distinct from the
data length (schemaLoading vs entities.length === 0 — a genuinely
empty schema must never spin forever), gate the empty state on
! loading, and render a loading placeholder while in flight. Detail
forms are worse than cosmetic: the assistants edit form seeded
system_prompt: '' from the list row until GET /assistants/{id}
landed, so a premature save overwrote the stored prompt — disable form
saves (and show a "Loading…" cue) until the full record arrives. Swept
surfaces (2026-10-22): assistants edit form, RecordsPage, DashboardsPage
(schema + data), ChatChannelsPage. Already-safe patterns to copy:
MediaOpsPage's bootstrapLoading || loading early return, TeamsPage's
loadingMembers ternary, the console DashboardPage's assistantsLoaded
LoadingDash stat placeholder.
- Wrong-store meters — a displayed metric measures a different store than
the data actually lives in. Real case (2026-10-23): the status-bar
"Sessions: X KB" figure summed same-origin localStorage keys
(
wp_mcp_ai_*, nvoos-chat-spa*, nvoos-pro-spa*), designed around the
legacy base chat writing history to localStorage. The Pro SPA stores
transcripts server-side (JetEngine CCT via /chat-transcripts), so
the meter read ~0 bytes and rendered "0 KB"; the standalone
nvoos-complete-velocity app runs on a different origin where the base
chat's keys can never exist at all. Fix pattern (2026-10-23, PR against
alpha-working): server-extend an aggregate into the list envelope — the
transcripts list now carries storage_bytes (sum of payload lengths for
the user, 0 in the transcripts-unavailable branch) — and have the client
prefer the server figure, falling back to the localStorage meter only
when the server value is unknown (older installs / endpoint down). When
auditing a displayed metric, first verify where the underlying data
lives and which origin the app runs on, before trusting the meter.
Audit path per client (4 hops, verify every hop)
- Client: endpoint key + methods + request fields + parsed response fields.
- Config emission:
addons/pro/includes/class-wp-mcp-ai-pro-spa-config.php
(what URL the key actually gets; note $is_admin && ! $guest localization).
- Route:
includes/rest/*.php, addons/pro/includes/rest/*.php,
includes/class-wp-mcp-ai-rest.php — method + permission + arg schemas.
Then find the boot path (grep 'Controller::init' addons/pro/includes/ addons/pro/mcp-ai-wpoos-pro.php): the registration must not sit behind an
admin-context gate (wp_mcp_ai_pro_load_admin_sections(), a registry module
with context => 'admin', or an is_admin() guard) — bug class 8.
- Handler: the exact response array. Quote decisive lines; don't trust the
docblock.
Route-presence cross-check (live index)
Fetch the namespace index and diff its route map against the client list —
this is the only check that catches registration-context gating:
curl -s https://<site>/wp-json/mcp-ai-pro/v1 | jq '.routes | keys'
curl -s https://<site>/wp-json/mcp-ai/v1 | jq '.routes | keys'
A route that 404s with the core "No route was found…" body is unregistered;
401/403 (or a plugin-specific 404 envelope) means it registered fine and the
failure is auth or data. In tests, the same check is
rest_get_server()->get_routes( 'mcp-ai-pro/v1' ) after a fresh
rest_api_init — never trust a controller test that registers the routes
manually as a fallback (see bug class 8).
Fix decision rules
- Server is the source of truth when other consumers exist (chat drawer,
MCP clients, wp-admin) — align the client and update its vitest contract
test to the real field names.
- Server-extend when the seam is cheap and admin-only: add the field to
the existing response (teams
description, presets icon, records
research title), or add the route when the console legitimately needs it
(assistants GET/PUT — full record incl. system prompt, manage_options-
gated; PUT accepts the create handler's field names).
- Promote the gatherer — never re-implement a query in a controller;
expose the wp-admin page's private gatherer as a public static seam.
- Client-derive for presentational data with no server source (tools
category from the slug prefix; verb-prefix set falls back to "Core").
- REST callbacks return
rest_ensure_response(...) (or WP_Error) — raw
arrays break ->get_data() consumers and are wrong for WP core.
Known-good baseline (2026-10-21 audit)
Verified contract-clean: settings, security, diagnostics, operations,
token-manager (credentials controller), orchestration-stats, pro-ops,
schedule-manager (+ presets), portability, approvals, hitl, chatProfile,
memory, threads, transcripts, toolShortcuts, slashCommands (chat drawer),
triggers, okf, workflow-builder, workflow-presets, records list, media
(wp/v2), config envelope. Re-audit only if one of these changes.
2026-10-22 additions: the features map (GET /mcp-ai/v1/features →
WP_MCP_AI_Feature_Flags::all()) now carries toolkit enable toggles —
fnb, crm, project_management, calendar_booking — and the console nav
- route guards gate
/fnb, /crm, /pm, /calendar on them (fail-open
when the map is absent). The dashboards catalogue (/dashboards/schema) is
server-filtered by
WP_MCP_AI_Pro_Dashboards_REST_Controller::is_dashboard_available() —
disabled toolkits (health/wellness, vitals, eca, law, cre, registration) are
omitted from the schema and their /dashboards/{id}/data returns 404
dashboards_unavailable; re-audit tests must seed the wp_mcp_ai_settings
toolkit flags (toolkits are default-off). GET /mcp-ai-pro/v1/schedules is
owned exclusively by the schedule CRUD controller ({ ok, schedules, total });
the scheduled-result picker lives at /schedules/selectable (bare array) —
bug class 9.
2026-10-23 addition: the chat-transcripts list envelope
(GET /mcp-ai/v1/chat-transcripts) now includes storage_bytes — the
aggregate byte size of the user's stored transcripts (repository
WP_MCP_AI_Transcript_Repository::get_storage_bytes(), seam
WP_MCP_AI_REST::get_transcript_storage_bytes()) — plus the Pro SPA
StatusBar prefers it over the same-origin localStorage meter (bug class 11).
Re-audit the transcripts client with this field in mind; it is absent on
older base-plugin installs (client falls back).
Registration pass (same-day follow-up): the six Proposal 084 console
bridges (ops overview, workflow-presets, workflow-builder node exec, records,
dashboards, media-cc) were contract-clean but admin-gate-registered — the
live console 404'd all six (bug class 8). They now boot from the context-free
rest_console_bridges module and are pinned by
tests/rest-api/test-rest-console-bridges.php. When re-auditing any of them,
verify route presence (live index or rest_get_server()->get_routes()),
not just shape.
Parallel delegation pattern
Split the client list into disjoint groups and spawn read-only audit
sub-agents; keep one coordinator for fixes + final gates.
- Disjoint write scopes if agents fix too; never let two agents edit the
same file (e.g. the big
includes/class-wp-mcp-ai-rest.php).
- Never run Docker PHPUnit concurrently (shared
wordpress_test DB) — the
coordinator runs the PHPUnit matrix at the end.
- Never run bundle rebuilds concurrently (shared
assets/dist/ +
dist/) — coordinator rebuilds once after all spa-v2 edits.
- Agents verify with
php -l, per-file phpcs, narrow npx vitest run <file>,
npm run typecheck; they do NOT commit, push, or touch dist/.
Validation matrix (coordinator)
- phpcs:
phpcbf <files> then phpcs --standard=phpcs.xml.dist --error-severity=1 --warning-severity=8 --report=summary <files>; EXIT 0
clean. Ignore false-positive warnings on meta_key/tax_query/
self::POST_TYPE string literals. phpcbf edits the bind-mounted file —
re-run php -l on the host afterwards.
- PHPUnit on both WP 6.9 and WP 7.1 (Docker commands in
mcp-ai-wpoos-test-suite; use the mcp-ai-wpoos-vendor volume).
- spa-v2:
npm run typecheck, npx vitest run (full), npm run lint:a11y,
then npm run build (esbuild).
- Console:
npx tsc --noEmit, npx vitest run, npm run build (vite) in
examples/nvoos-complete-velocity; commit the hashed dist renames.
- Tests that need real rows must create real posts (tools validate via
get_post()); zero-network envelopes are the norm.
Gotchas
- Contract tests that bake in phantom fields mask real drift — rewrite
them from the server's actual response, not the client's types.
_fields filtering is applied after caching in the assistants/tools list
handlers; don't assume a filtered key exists in the cached shape.
- Two token-manager controllers coexist (
/token-manager/* used by the SPA
vs /users/{id}/token-tier) — don't patch the wrong one.