Skip to content

Compare the core read abilities against the REST API - #931

Draft
gziolo wants to merge 8 commits into
developfrom
try/core-read-abilities-rest-backend
Draft

Compare the core read abilities against the REST API#931
gziolo wants to merge 8 commits into
developfrom
try/core-read-abilities-rest-backend

Conversation

@gziolo

@gziolo gziolo commented Aug 12, 2026

Copy link
Copy Markdown
Member

What?

core/read-content, core/read-settings and core/read-users reimplement logic the REST API already has. This PR adds a second execute implementation for each one that calls the matching REST endpoint instead, so we can measure how closely our versions match REST behaviour.

Both implementations are always loaded. One switch picks which runs:

define( 'WPAI_ABILITIES_REST_BACKEND', true );   // wp-config.php
add_filter( 'wpai_abilities_rest_backend', '__return_true' );  // runtime
npm run test:php        # the implementations in this repo
npm run test:php:rest   # the same suite, through the REST API

Why?

The three abilities are kept close to their WordPress core counterparts, and much of what they do overlaps with the REST controllers. Running the same suite against both answers a question we could otherwise only argue about: where do our versions and REST actually disagree?

Result: the same suite passes both ways — 1238 tests, 3563 assertions, 35 skipped, identical on both sides. For everything the tests cover, the two behave the same.

That is the useful finding, and also the limit of the claim: it says the suite cannot tell them apart, not that they are equivalent.

Where they genuinely differ

Every difference runs the same direction — our version is stricter or more careful:

  • it skips post meta priming when nothing renders; the posts endpoint always primes featured-image meta
  • it restores the global post after rendering; the endpoint leaves it on the last item
  • it derives a GMT date from the local date when the stored column is NULL; the endpoint returns nothing
  • it reads show_avatars per call; the endpoint caches its schema per request
  • it fails closed on orphaned inherit posts and public-but-not-viewable statuses

None of these look worth changing. The cost of the REST path is a fixed handful of extra queries per request — 8 vs 4 on a lean projection, level once content is rendered.

How?

Only the execute callbacks switch. Permission callbacks stay with the current implementation — they are the abilities' own authorization contract, and the endpoints answer a related but different question (REST hands roles to anyone who can list users; the ability wants edit access).

File Endpoint
Rest_Backend.php the switch, plus a small rest_do_request() helper
Content_Rest.php GET /wp/v2/<rest_base> and /<id>
Users_Rest.php GET /wp/v2/users and /wp/v2/users/<id>
Settings_Rest.php GET /wp/v2/settings

The three ability classes change by 41 lines — // Plugin: branches at the points where each produced its output. Input parsing and filter validation stay shared.

The mapping is mostly shape: title.renderedtitle_rendered, typepost_type, dates to full ISO 8601 (REST omits the offset), the author ID expanded to {id, name}. A few things have no request parameter and travel through scoped query filters instead: perm and cache priming for content, has_published_posts for users. Post types exposed with show_in_abilities but not show_in_rest have no route, so the flag is turned on for the length of the request.

Two changes to the abilities themselves

core/read-users collection order. It ordered by user login (the WP_User_Query default) while /wp/v2/users orders by display name, and the output schema documented neither. It is now ordered by display name and stated in the schema.

This is the one behaviour change to the plugin here, and it stands on its own. It deserves a separate follow-up PR so it can be reviewed on its own terms instead of riding along with an experiment. It is three self-contained hunks in Users.php.

The lean projection cache test now asserts what the ability asks for (update_post_meta_cache on the query it builds) rather than counting the queries that follow. Honoring the request belongs to whoever runs the query. Verified it still fails if should_prime_post_caches() is gutted.

CI

test.yml gains one matrix entry (PHP 8.3, WP latest) that runs the suite through the REST path, so the second implementation cannot rot silently. It shows up as its own job alongside the existing ones.

Use of AI Tools

AI assistance: Yes
Tool(s): Claude Code
Model(s): Claude Opus 5
Used for: The REST-backed implementations, the input and output mapping, the CI matrix entry, and this description were drafted with Claude Code and iterated against the existing test suite. The comparison this PR reports was run and verified in a local wp-env environment. I reviewed and edited the result, and I take responsibility for it.

Testing Instructions

  1. npm run wp-env:test start
  2. npm run test:php — the implementations in this repo.
  3. npm run test:php:rest — the same suite, executed through the REST API.

Both should report the same totals.

To compare a single call by hand, run an ability twice with the filter toggled:

$input = array( 'post_type' => 'post', 'fields' => array( 'id', 'title_rendered', 'date' ) );

$native = wp_get_ability( 'core/read-content' )->execute( $input );

add_filter( 'wpai_abilities_rest_backend', '__return_true' );
$rest = wp_get_ability( 'core/read-content' )->execute( $input );

var_dump( $native === $rest ); // true

Changelog Entry

Developer - Add an optional REST-backed execute implementation for the core/read-content, core/read-settings and core/read-users abilities, so both can be compared against the same test suite.

Open WordPress Playground Preview
@github-actions

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@codecov

codecov Bot commented Aug 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 69.08078% with 111 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.23%. Comparing base (788e1a6) to head (aa4b048).

Files with missing lines Patch % Lines
includes/Abilities/Content/Content_Rest.php 69.23% 60 Missing ⚠️
includes/Abilities/Users/Users_Rest.php 72.63% 26 Missing ⚠️
includes/Abilities/Settings/Settings_Rest.php 18.51% 22 Missing ⚠️
includes/Abilities/Content/Content.php 77.77% 2 Missing ⚠️
includes/Abilities/Settings/Settings.php 85.71% 1 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop     #931      +/-   ##
=============================================
- Coverage      74.39%   74.23%   -0.17%     
- Complexity      3110     3256     +146     
=============================================
  Files            132      136       +4     
  Lines          12166    12520     +354     
=============================================
+ Hits            9051     9294     +243     
- Misses          3115     3226     +111     
Flag Coverage Δ
unit 74.23% <69.08%> (-0.17%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.
@gziolo
gziolo force-pushed the try/core-read-abilities-rest-backend branch from c7ed7dc to 54e04c8 Compare August 12, 2026 08:16
@jeffpaul jeffpaul added this to the Future Release milestone Aug 12, 2026
@jeffpaul jeffpaul moved this from Triage to In progress in WordPress AI Roadmap Aug 12, 2026
@galatanovidiu

Copy link
Copy Markdown

I think using the REST API as the backend for these Abilities is a good solution. For mature Core endpoints that already own the operation, REST should normally be the preferred and single execution path.

Why I believe this is the right direction

  • It reuses mature execution logic instead of duplicating queries, validation, permissions, formatting, pagination, context handling, filtering, object preparation, and edge cases.
  • It adds the REST route permission callback as a second authorization checkpoint after the Ability permission callback.
  • It automatically inherits security fixes, correctness fixes, and backward-compatible behavior from the endpoint.
  • It preserves the existing Core and plugin REST hooks, so REST clients and agents see consistent behavior.
  • It builds on functionality already exercised in production and covered by endpoint integration tests.
  • It reduces long-term maintenance and the risk that the REST and Ability implementations drift apart.
  • It keeps the Ability focused on the agent-facing contract: its schema, annotations, and first permission check.
  • It also gives mature plugin REST functionality a path to become agent-accessible without reimplementing the operation.

This is not perfect, though. REST has some specific weaknesses here:

  • Internal dispatch adds routing, validation, controller, and response-preparation overhead.
  • Some mappings can require additional REST calls and create N+1 behavior.
  • REST controllers can modify request-global state that the Ability backend then has to restore correctly.
  • Nested Ability-to-REST execution makes debugging and observability more complicated.
  • rest_do_request() does not exercise external HTTP authentication or infrastructure checks such as proxies and WAF rules.
  • An endpoint designed for an HTTP response can do work that an in-process caller does not need.
  • Poorly implemented plugin endpoints may behave unpredictably during internal dispatch.
  • This pattern cannot help when there is no strong matching REST route.

There are also neutral trade-offs rather than inherent weaknesses:

  • Coupling to REST is useful when the Ability should match the established endpoint, but restrictive when the Ability intentionally needs different semantics.
  • Two permission checks are useful as defense in depth, but the stricter contract always wins if the Ability and route permissions legitimately differ.
  • Inheriting REST hooks is useful when existing integrations should affect the Ability, but not when hooks written for human-facing HTTP requests produce inappropriate agent behavior.
  • REST backward compatibility provides a stable foundation, but can constrain an Ability that needs to evolve independently.
  • Input and output translation is normal for any Ability. It only becomes a REST problem when the two contracts differ enough to make the adapter disproportionately complex.
  • Preserving REST errors is useful when those are the intended failure semantics, but not when the errors are too transport-oriented for agents.
  • REST view and edit contexts are useful when they match the Ability's visibility rules, but not when the Ability needs a different field policy.
  • REST should be canonical when the endpoint already owns the operation. If REST is only a transport over reusable domain logic, both should call that lower-level implementation instead.
  • Endpoint changes propagating automatically is useful for compatible fixes, but not if the Ability promises behavior independent from REST.

What I reproduced in this branch

I compared this smaller backend with the full Abilities REST Adapter and exercised the risky paths against WordPress. I found five concrete gaps that I think we should fix without importing the generic adapter:

  1. Rest_Backend::get() uses WP_REST_Request::set_param() for query arguments. That depends on the filterable REST parameter order. When URL parameters are ordered before query parameters, route matching overwrites those values. In the runtime probe, include=[123] became [] and per_page=1 became 10. These arguments should be written explicitly with set_query_params().
  2. Rest_Backend::data() converts any successful non-array response into []. A malformed scalar response therefore looks like a valid empty result. In the runtime probe, "malformed-success-body" became users=[], total=0, and pages=0. Unexpected successful response shapes should fail closed, and the specialized collection adapters should reject malformed rows.
  3. Settings_Rest::get_values() turns a REST error into [], after which the settings Ability falls back to get_option(). In the runtime probe, a 403 from /wp/v2/settings still returned the site title. The stock permission callbacks currently both require manage_options, so I did not reproduce a stock privilege escalation, but this still bypasses extra REST policy and hooks. The REST error should be propagated, and fallback should only fill absent settings after a successful response.
  4. Users_Rest::query_users() filters out per-user WP_Error results. In the runtime probe, a denied sensitive-field request returned users=[] while the collection metadata still said total=1 and total_pages=1. A per-item authorization or execution failure should be propagated instead of silently dropping the row.
  5. Content_Rest::prepare_post_type() restores the previous REST server only when one already existed. When no server existed before the call, internal dispatch creates one and leaves the temporary route registered after the post type itself has been restored. The cleanup should also unset the newly created server when the previous value was null.

These look like narrow integration gaps, not reasons to move away from REST. My preference is to keep REST as the single execution path for these Abilities, fix the five cases above, and avoid bringing the full generic adapter into this PR.

@gziolo

gziolo commented Aug 13, 2026

Copy link
Copy Markdown
Member Author

Thank you for the review. I could reproduce all five findings, and each one is now fixed in its own commit:

  1. 44c68b3eprepare_post_type() unsets the REST server when there was none before.
  2. da39adfb — a settings endpoint error is passed on instead of falling back to get_option().
  3. 6a657e49 — a user row that fails ends the read instead of being dropped while total still counts it.
  4. 43d86b6fRest_Backend::data() fails closed on a response it cannot read.
  5. 32834716 — parameters use set_query_params(), so rest_request_parameter_order no longer affects them.

Each fix has a test that fails without it. Both suites pass with the same totals, and phpcs and phpstan are clean.

gziolo added 8 commits August 19, 2026 09:19
`core/read-content`, `core/read-settings` and `core/read-users` repeat logic
that the REST API already implements. Each one now ships a second execute
implementation that calls the matching REST endpoint, maps the ability input
to request parameters, and maps the response back to the ability output shape.

Both implementations are always loaded. A constant or filter picks which one
runs, so the same test suite covers both and the two can be compared:

    define( 'WPAI_ABILITIES_REST_BACKEND', true );
    add_filter( 'wpai_abilities_rest_backend', '__return_true' );

Only the execute callbacks switch. The permission callbacks stay with the
native implementation, because they are the abilities' own authorization
contract, and the endpoints answer a related but different question.

Also aligns the `core/read-users` collection order with the REST users
controller and documents it in the output schema, and narrows the lean
projection cache test to what the ability asks for rather than the queries
that follow.
`Content_Rest::prepare_post_type()` exposes a post type to REST for the
length of one request, and unsets the REST server so a fresh one is built
with a route for that post type. The restore step put the previous server
back, but only when one existed. When none existed, the temporary server
stayed in the global with the route still registered, after the post type
had already been set back to not exposed. Any later internal request in
the same process then saw a route that should not be there.

This needs no special setup. It happens in any request where no REST
server has been built yet, such as WP-CLI or a regular admin request.

Unset the global in that case, so the next caller builds a fresh server
from the restored state.
`Settings_Rest::get_values()` returned an empty array when the settings
endpoint failed. An empty array means "REST exposes none of these
settings", so `Settings::execute_get_settings()` then read every value
from `get_option()` and returned it. A request the endpoint refused was
answered anyway, from the stored options.

On stock WordPress both permission callbacks require `manage_options`, so
this is not a privilege escalation today. It still defeats the point of
the REST-backed path: it skips the endpoint's policy and its filters at
exactly the moment the endpoint says no.

Return the error instead, and pass it on from the ability. The fallback to
the stored option stays for settings a successful response did not include,
which is what it was there for.
Rows that ask for sensitive fields are read one by one through
`/wp/v2/users/<id>`. When one of those reads failed, `query_users()`
filtered the error out of the list, but `total` and `total_pages` still
came from the collection headers. The page then held fewer users than the
totals promised, and the caller could not tell a user that was withheld
from one that does not exist.

Return the error instead, so the whole read fails and says why.
`Rest_Backend::data()` turned any successful response that was not an
array into an empty array. A malformed body then looked exactly like a
valid empty result: no users, no posts, no settings, and no sign that
anything went wrong.

Return an error instead, and pass it on from every caller. Collection rows
follow the same rule: a row that is not an array, or has no `id`, or does
not resolve to a user, now ends the read rather than being skipped. A
skipped row leaves the page short while `total` still counts it, which is
the problem the previous commit fixed for failed rows.

Core alone does not produce these shapes. A filter on the response can, and
when it does, "empty" is the wrong thing for a read ability to report.
`Rest_Backend::get()` set each parameter with `set_param()`, which writes
to whichever parameter type comes first in the request's parameter order.
That order is filterable through `rest_request_parameter_order`. Stock
WordPress puts `GET` first, so the parameters land where they belong. With
`URL` first they land in the URL parameters instead, and dispatching
replaces all of those with the ones matched from the route. The parameters
are gone before the endpoint sees them: `include` becomes empty and
`per_page` falls back to its default of 10.

Write them straight to the query parameters, which is what they would be
over HTTP. The behavior no longer depends on a filter that has nothing to
do with this request.
Version 1.3.0 shipped without this code, so the new files carry the
placeholder that CONTRIBUTING.md asks for. The version is filled in on
release.
@gziolo
gziolo force-pushed the try/core-read-abilities-rest-backend branch from 41fc5a5 to aa4b048 Compare August 19, 2026 07:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

3 participants