Repository navigation
Add core/content-create, core/content-update, and core/content-delete abilities - #1025
jorgefilipecosta wants to merge 182 commits into
Conversation
✅ WordPress Plugin Check Report
📊 ReportAll checks passed! No errors or warnings found. 🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #1025 +/- ##
=============================================
+ Coverage 81.62% 82.78% +1.15%
- Complexity 3088 3528 +440
=============================================
Files 129 136 +7
Lines 12300 13603 +1303
=============================================
+ Hits 10040 11261 +1221
- Misses 2260 2342 +82
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
259fdf5 to
5e35c13
Compare
|
@jorgefilipecosta moving this to the 1.5.0 milestone that's currently targeted for the end of October, but happy to cut that release sooner once this PR is ready so that we can get it into the AI plugin and provide an outlet to ease approval of getting this into core in time for the WP 7.2 beta 1 timeline on October 20th. |
Testing using the browser consoleThe new abilities can also be tested from the browser console with the client-side Abilities API. I ran these steps on wp-env (WordPress 7.1.2) at 7eefd2a, and the outputs below are from that run. Post IDs, timestamps, and links will differ on your site. Setup
0. Load the API and a logging helper await ( await import( '@wordpress/core-abilities' ) ).ready;
const { executeAbility, getAbility } = await import( '@wordpress/abilities' );
const run = async ( name, input ) => {
console.log( `▶ ${ name }`, input );
try {
const output = await executeAbility( name, input );
console.log( '✔ output', output );
return output;
} catch ( error ) {
console.log( '✖ error', error.code, error.message, error.data ?? '' );
return error;
}
};
1. Create a draft const created = await run( 'core/content-create', {
post_type: 'post',
title_raw: 'Hello from the Abilities API',
content_raw: '<!-- wp:paragraph -->\n<p>Written by core/content-create.</p>\n<!-- /wp:paragraph -->',
excerpt_raw: 'Created from the browser console.',
status: 'draft',
fields: [ 'id', 'post_type', 'status', 'date', 'slug', 'link', 'title_raw', 'excerpt_raw', 'content_raw', 'author_slug' ],
} );Output: {
id: 7,
post_type: 'post',
status: 'draft',
date: '2026-10-06T17:11:15+00:00',
slug: '', // a draft gets no slug until it is published
link: 'http://localhost:8889/?p=7',
title_raw: 'Hello from the Abilities API',
excerpt_raw: 'Created from the browser console.',
content_raw: '<!-- wp:paragraph -->\n<p>Written by core/content-create.</p>\n<!-- /wp:paragraph -->',
author_slug: 'admin', // the user's slug, as core/users-query returns it
}2. Read it back by ID const read = await run( 'core/content-query', {
id: created.id,
fields: [ 'id', 'status', 'title_rendered', 'excerpt_rendered', 'content_rendered', 'author_slug' ],
} );
const readDefault = await run( 'core/content-query', { id: created.id } );Output: // read
{
id: 7,
status: 'draft',
title_rendered: 'Hello from the Abilities API',
excerpt_rendered: '<p>Created from the browser console.</p>\n',
content_rendered: '\n<p class="wp-block-paragraph">Written by core/content-create.</p>\n',
author_slug: 'admin',
}
// readDefault: without `fields`, the default field set
{ id: 7, post_type: 'post', status: 'draft', date: '2026-10-06T17:11:15+00:00', slug: '', title_rendered: 'Hello from the Abilities API' }3. Update: rename, set the slug, publish const updated = await run( 'core/content-update', {
id: created.id,
title_raw: 'Hello again from the Abilities API',
slug: 'hello-abilities-api',
status: 'publish',
fields: [ 'id', 'status', 'date', 'modified', 'slug', 'link', 'title_raw', 'excerpt_raw', 'content_raw' ],
} );Output: {
id: 7,
status: 'publish',
date: '2026-10-06T17:12:49+00:00', // the draft's date was floating, so publishing sets it to now
modified: '2026-10-06T17:12:49+00:00',
slug: 'hello-abilities-api',
link: 'http://localhost:8889/hello-abilities-api/',
title_raw: 'Hello again from the Abilities API',
excerpt_raw: 'Created from the browser console.', // fields left out keep their values
content_raw: '<!-- wp:paragraph -->\n<p>Written by core/content-create.</p>\n<!-- /wp:paragraph -->',
}4. Query: by slug, by author, a filtered list, and specific IDs const bySlug = await run( 'core/content-query', {
post_type: 'post',
slug: 'hello-abilities-api',
fields: [ 'id', 'status', 'slug', 'link', 'title_rendered' ],
} );
const byAuthor = await run( 'core/content-query', {
post_type: 'post',
author_slug: created.author_slug,
fields: [ 'id', 'title_rendered', 'author_slug' ],
} );
const list = await run( 'core/content-query', {
post_type: 'post',
status: [ 'publish', 'draft' ],
per_page: 5,
fields: [ 'id', 'status', 'title_rendered' ],
} );
const byIds = await run( 'core/content-query', {
post_type: 'post',
include: [ created.id, 1 ],
fields: [ 'id', 'title_rendered' ],
} );Output: // bySlug: a single post is returned directly
{ id: 7, status: 'publish', slug: 'hello-abilities-api', link: 'http://localhost:8889/hello-abilities-api/', title_rendered: 'Hello again from the Abilities API' }
// byAuthor: the posts of the user with that slug (on a fresh site)
{
posts: [
{ id: 7, title_rendered: 'Hello again from the Abilities API', author_slug: 'admin' },
{ id: 1, title_rendered: 'Hello world!', author_slug: 'admin' },
],
total: 2,
total_pages: 1,
}
// list: newest first, with page counts (on a fresh site; other posts on the site are listed too)
{
posts: [
{ id: 7, status: 'publish', title_rendered: 'Hello again from the Abilities API' },
{ id: 1, status: 'publish', title_rendered: 'Hello world!' },
],
total: 2,
total_pages: 1,
}
// byIds
{ posts: [ { id: 7, title_rendered: 'Hello again from the Abilities API' }, { id: 1, title_rendered: 'Hello world!' } ], total: 2, total_pages: 1 }5. Delete: trash, trash again, delete permanently, read again const trashed = await run( 'core/content-delete', {
id: created.id,
fields: [ 'id', 'status', 'slug', 'title_raw' ],
} );
const trashedAgain = await run( 'core/content-delete', { id: created.id } );
const deleted = await run( 'core/content-delete', {
id: created.id,
force: true,
fields: [ 'id', 'status', 'title_raw' ],
} );
const gone = await run( 'core/content-query', { id: created.id } );Output: // trashed: without `force`, the post is trashed and returned
{ id: 7, status: 'trash', slug: 'hello-abilities-api__trashed', title_raw: 'Hello again from the Abilities API' }
// trashedAgain: error
{ code: 'content_already_trashed', message: 'The post has already been deleted.', data: { status: 410 } }
// deleted: `force: true` deletes the post and returns it as it was before the deletion
{ id: 7, status: 'trash', title_raw: 'Hello again from the Abilities API' }
// gone: error, the permission check refuses a post that does not exist
{ code: 'rest_ability_cannot_execute', message: 'Sorry, you are not allowed to execute this ability.', data: { status: 403 } }6. Input validation and scheduling const noType = await run( 'core/content-create', { title_raw: 'No post type' } );
const noOffset = await run( 'core/content-create', { post_type: 'post', title_raw: 'No timezone offset', date: '2027-01-01T09:00:00' } );
const unknownAuthor = await run( 'core/content-create', { post_type: 'post', title_raw: 'Unknown author', author_slug: 'no-such-user' } );
const unknownAuthorFilter = await run( 'core/content-query', { post_type: 'post', author_slug: 'no-such-user' } );
const scheduled = await run( 'core/content-create', {
post_type: 'post',
title_raw: 'Scheduled by an ability',
status: 'publish',
date: '2027-01-01T09:00:00+00:00',
fields: [ 'id', 'status', 'date', 'date_gmt' ],
} );
const cleanup = await run( 'core/content-delete', { id: scheduled.id, force: true, fields: [ 'id', 'status' ] } );Output: // noType and noOffset: thrown by the client's input validation, before any request is sent
{ code: 'ability_invalid_input', message: 'Ability "core/content-create" has invalid input. Reason: post_type is a required property of input.' }
{ code: 'ability_invalid_input', message: 'Ability "core/content-create" has invalid input. Reason: Invalid date.' }
// unknownAuthor and unknownAuthorFilter: errors from the server; nothing is written
{ code: 'content_invalid_field', message: 'The author_slug field must be the slug of an existing user.', data: { status: 400 } }
{ code: 'content_invalid_filter', message: 'The author_slug filter must be the slug of an existing user.', data: { status: 400 } }
// scheduled: a future date with `publish` schedules the post
{ id: 9, status: 'future', date: '2027-01-01T09:00:00+00:00', date_gmt: '2027-01-01T09:00:00+00:00' }
// cleanup
{ id: 9, status: 'future' }The client's validation requires a timezone offset or a trailing 7. Page parents const parentPage = await run( 'core/content-create', {
post_type: 'page',
title_raw: 'Parent page',
status: 'publish',
fields: [ 'id', 'link', 'parent' ],
} );
const childPage = await run( 'core/content-create', {
post_type: 'page',
title_raw: 'Child page',
status: 'publish',
parent: parentPage.id,
fields: [ 'id', 'link', 'parent' ],
} );
const loop = await run( 'core/content-update', { id: parentPage.id, parent: childPage.id } );
const pagesCleanup = [
await run( 'core/content-delete', { id: childPage.id, force: true, fields: [ 'id' ] } ),
await run( 'core/content-delete', { id: parentPage.id, force: true, fields: [ 'id' ] } ),
];Output: // parentPage and childPage
{ id: 10, link: 'http://localhost:8889/parent-page/', parent: 0 }
{ id: 11, link: 'http://localhost:8889/parent-page/child-page/', parent: 10 }
// loop: error, a page cannot move under one of its own descendants
{ code: 'content_invalid_field', message: 'The parent field must be 0 or the ID of a readable post of the same type, other than the post itself or one of its descendants.', data: { status: 400 } }
// pagesCleanup
[ { id: 11 }, { id: 10 } ] |
|
The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message. To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook. |
|
One thing I want to flag is This is problematic for agents and risks corrupting large amounts of data. It's also extremely token inefficient. There is a PR from the contributor day at WCUS that addresses this, and at least could be looked at as a base for ideas #945 |
I missed the PR/ping thank you for raising awareness @apeatling. And thank you for the work at #945 @seedprod. I think overwriting the whole content like this PR does is going to be needed as it is basic functionality but I totally agree it should not be the only way as it may be token inefficient and can cause problems on large posts. I guess we could get this PR merged, and then as follow explore options using additional fields for partial updates either as string replace on old_content #945, or with positions offsets where the new content is added or both? Would be happy to collaborate with @seedprod on that. Would that work @apeatling ? |
Agreed, no need to be a blocker, although I think it is a semi-dealbreaker for agent usage. Makes sense to follow up. |
Yes, agreed there, but this PR is already huge I guess we can try to merge this one as soon as possible and then iterate fast on this option as a follow up. I will try to propose a draft follow up based on this PR (which will need updates based on what we change here). This partial updates option will need some thinking, it may even be an object so in the future we can do some nice things like expand to be block aware and/or html API aware. |
|
Thanks for all the work on this PR, Jorge! I really appreciate the time you've spent implementing, testing, and iterating on these abilities. The direction is sound and follows the principles we agreed on: each ability has distinct behavior expressed through its input/output schemas and annotations. Let's follow the input/output shape established in
Happy to go into details on any of these. |
b798181 to
e439910
Compare
|
Could we use By The proposal would use
Resolution should remain straightforward: look up the user by slug, then use their ID internally for querying, assignment, and capability checks. Unknown or ambiguous slugs should fail before querying or writing, never silently drop the filter. Omitting the field would leave queries unrestricted by author, use the current user on create, and preserve the existing author on update. We should explicitly decide how to handle compatibility with the query’s existing |
- Register the abilities in setUp(). Tests that register a post status or a post type first register them again. - Share the query read and the floating draft set-up in ContentUpdateTest, and the create and update round trip in ContentCreateTest. The editor round trip reuses the author case with a script. - Remove tests that others already cover: an update with only text fields, an author updating their own draft, an invalid status and an empty author slug on update, which uses the create schema, and a non-string title filter on create, which ContentTest covers. - Check dates out of step with the timezone after a change to Asia/Tokyo instead of Europe/Lisbon, which is on GMT in December. With no offset, a date's local and GMT values are the same, so the test could not tell which one the update compared. The imported post case adds nothing then. - Inline the helpers that only one test used, move assertNoPostTitled() to ContentCreateTest, and let assertAbilityError() check the status. - Drop the checks that nothing was written after an error from validation or the permission check, which run before the ability does.
gziolo
left a comment
There was a problem hiding this comment.
Two remaining issues noted inline.
core/content-create, core/content-update, and core/content-delete abilities
- Keep only a `show_in_abilities` value of `true` on registered post types until core's WP_Post_Type::set_props() does, so other values, such as arrays, can be given a meaning later. - Count the pages with the page size the query ran with, since `pre_get_posts` callbacks can change it. - Read `page` and `per_page` with absint(), so whole numbers such as 2.0 are honored. - Keep the loop globals of a global post that was never set up, so get_the_content() outside the loop does not throw. - Fold to_output_post() into format_post() and the date helpers into format_date(), inline the raw title format callback, and stop caching the post field definitions. - Keep the future date an update sends back when it publishes a draft without a fixed date, as when it schedules one, so the post is scheduled for that date.
- Rename the `post_type` input and output field to `type` in every content ability, so a returned post's `type` can be sent back as it is. Callers of 1.4.0 that send `post_type`, including through the `core/read-content` alias, must switch to `type`. - Accept any whole number the integer schema accepts for IDs, `parent`, `page`, and `per_page`, such as 2.0, "2.0", or "+2", up to 2 ** 53. - Report a page past the last one as not found (404). - Always include the `id`, so a post is never an empty object. - Look up an author slug as given, so the database collation decides whether a variant in another case matches. The write abilities compare slugs with the same lookup, so an author can name themselves in another case, and sending back the stored author in another case keeps it. - Use `! empty()` for the field checks. - Update the tests and the e2e specs to match.
- Explain in the class docblock why the content ability lives in a dedicated class instead of a closure in wp_register_core_abilities(), as the users and settings classes do, and refer to the Core class by its current name, `WP_Abilities_Content`. - Say that an empty `fields` list, like an omitted one, returns the lean set of common read fields.
…abilities - Return null instead of an empty string for a date that cannot be resolved, and declare the `date-time` format on the date fields. A malformed stored date that the format rejects is reported as null too, instead of failing output validation for the whole call. The write abilities still take a date as a string only. - Map the filter or field to the message under `params` in the `content_invalid_filter` and `content_invalid_field` error data, as the REST API's `rest_invalid_param` errors do, so callers can tell which input failed without parsing the translated message. - Mark `id`, which is always returned, as required in the post output schema. - Skip posts of other post types that query filters add, in query mode and in the slug lookup, as core does. The earlier port missed this, and left a stale `Plugin:` note in its place.
Only init(), register_category(), and register() stay public. The permission and execute callbacks of `core/content-query`, `core/content-create`, `core/content-update`, and `core/content-delete`, and the `post_password_required` filter that format_post() adds, are now private methods, reached through closures defined in the class, so callers run the abilities through the Abilities API, such as `wp_get_ability( 'core/content-query' )->execute()`, which validates the input and checks permissions first. Tests that call the callbacks directly now capture them from the registration arguments, through the `wp_register_ability_args` filter. The password filter test now reads `post_password_required()` while an editable protected post renders, instead of calling the filter callback. Ported from WordPress/wordpress-develop#12195.
A custom status can be publicly queryable without being public. Posts with such a status are publicly viewable, so core/content-query lets any logged-in user read them, but create and update only required the publish capability for statuses registered as public. A contributor could therefore show a post to everyone through that status. Also check is_post_status_viewable(), and cover a publicly queryable status in the create and update tests, which confirm that a refused request writes nothing.
The kses rewrite in WordPress 7.2 also removes the text inside a script element, which earlier versions keep, so the author round trip failed on trunk. Build the expected values from what wp_kses_post() leaves of the script, so the test passes on both.
…ery ability The content query now resolves the requested post with `get_content_by_id()` and the post type with `get_exposed_post_type()`, and builds its `fields` input and post output schemas with `get_fields_input_schema()` and `get_content_output_schema()`, as the AI plugin does so its content write abilities can reuse them (WordPress/ai#1025). `get_content_by_id()` parses the ID the way the integer filters are parsed, so only an integer or an unsigned integer string resolves a post.
…ery ability The query took an author user ID as its filter and returned the author as `author`, an object with the user ID and display name. It now takes and returns `author_slug`, the user's nicename, which `core/users-query` returns as `slug`, as the AI plugin does (WordPress/ai#1025). The slug must match exactly one user, or the filter fails as `content_invalid_filter`. A user the caller may not see in `core/users-query` is reported like a missing one, so the filter reveals no hidden user.
…urns a non-string `get_title()` returned whatever the `the_title` filters returned, so a filter that returns a non-string failed its string return type and turned the query into an `ability_callback_exception`. The rendered excerpt and content are already guarded this way, as the AI plugin does (WordPress/ai#1025).
…uery ability `check_read_permission()` calls `get_exposed_post_type()` for every post a query returns, up to 100 per page, and each call rebuilt the map of every exposed post type. It now reads the post type object and checks `show_in_abilities`, the same truthy rule `get_post_types()` applies, as the AI plugin does (WordPress/ai#1025). `get_exposed_post_types()` was then only used for the post type names at registration, which `get_post_types()` returns directly, so it is removed.
The `get_author_by_slug()` docblock said the lookup reveals no hidden user, as in `core/users-query`. A user who can edit others' posts of the post type, such as an editor, resolves any slug, since they may make any user the author, while `core/users-query` hides users like subscribers from them. The docblock now says so, as the AI plugin does (WordPress/ai#1025).
…docblock The docblock said the parser keeps `author => 0` from dropping the author filter, but the author filter is now `author_slug`, resolved by `get_author_by_slug()`. `parse_filter_int()` only reads the `id` input and the `parent` filter, so the docblock now describes those, as the AI plugin does (WordPress/ai#1025).
The class docblock and the `register()` comment named the future write ability `core/manage-content`, and the comment sketched a `register_manage_content()` call. The AI plugin is adding `core/content-create`, `core/content-update`, and `core/content-delete` instead (WordPress/ai#1025), so both now refer to future write-oriented content abilities in general.
The AI plugin's coding standards ask for early exits in loops, so its copy of `set_up_post_context()` skips unset globals with `continue`. Core now does the same, so the two classes stay identical (WordPress/ai#1025). The test helper that reads the loop globals follows the same shape.
Ports the class docblock wording from WordPress/wordpress-develop#12195. With `WP_Abilities_Settings` and `WP_Abilities_Content` both in Core, the other core abilities are no longer all self-contained closures, so the docblock contrasts the class with the core abilities that are registered as closures.
…ery ability The content query now resolves the requested post with `get_content_by_id()` and the post type with `get_exposed_post_type()`, and builds its `fields` input and post output schemas with `get_fields_input_schema()` and `get_content_output_schema()`, as the AI plugin does so its content write abilities can reuse them (WordPress/ai#1025). `get_content_by_id()` parses the ID the way the integer filters are parsed, so only an integer or an unsigned integer string resolves a post.
…ery ability The query took an author user ID as its filter and returned the author as `author`, an object with the user ID and display name. It now takes and returns `author_slug`, the user's nicename, which `core/users-query` returns as `slug`, as the AI plugin does (WordPress/ai#1025). The slug must match exactly one user, or the filter fails as `content_invalid_filter`. A user the caller may not see in `core/users-query` is reported like a missing one, so the filter reveals no hidden user.
…urns a non-string `get_title()` returned whatever the `the_title` filters returned, so a filter that returns a non-string failed its string return type and turned the query into an `ability_callback_exception`. The rendered excerpt and content are already guarded this way, as the AI plugin does (WordPress/ai#1025).
…uery ability `check_read_permission()` calls `get_exposed_post_type()` for every post a query returns, up to 100 per page, and each call rebuilt the map of every exposed post type. It now reads the post type object and checks `show_in_abilities`, the same truthy rule `get_post_types()` applies, as the AI plugin does (WordPress/ai#1025). `get_exposed_post_types()` was then only used for the post type names at registration, which `get_post_types()` returns directly, so it is removed.
The `get_author_by_slug()` docblock said the lookup reveals no hidden user, as in `core/users-query`. A user who can edit others' posts of the post type, such as an editor, resolves any slug, since they may make any user the author, while `core/users-query` hides users like subscribers from them. The docblock now says so, as the AI plugin does (WordPress/ai#1025).
…docblock The docblock said the parser keeps `author => 0` from dropping the author filter, but the author filter is now `author_slug`, resolved by `get_author_by_slug()`. `parse_filter_int()` only reads the `id` input and the `parent` filter, so the docblock now describes those, as the AI plugin does (WordPress/ai#1025).
The class docblock and the `register()` comment named the future write ability `core/manage-content`, and the comment sketched a `register_manage_content()` call. The AI plugin is adding `core/content-create`, `core/content-update`, and `core/content-delete` instead (WordPress/ai#1025), so both now refer to future write-oriented content abilities in general.
The AI plugin's coding standards ask for early exits in loops, so its copy of `set_up_post_context()` skips unset globals with `continue`. Core now does the same, so the two classes stay identical (WordPress/ai#1025). The test helper that reads the loop globals follows the same shape.
What?
Adds
core/content-create,core/content-update, andcore/content-deletenext tocore/content-query, and gives all four the author asauthor_slug.Why?
Agents can read posts with
core/content-querybut cannot write them. These abilities let an agent read a post, change its fields, and write it back under the same names, shapes, and rules.How?
The abilities live in
includes/Abilities/Content/Content.php, behind the Custom Abilities experiment, and register when a post type is exposed to abilities. Like the read abilities, they are markedpublic. Onlyinit(),register_category(), andregister()are public methods; as in core, the ability callbacks are closures over private methods.Inputs use the field names
core/content-queryreturns:title_raw,content_raw, andexcerpt_rawas plain strings, plusstatus,slug,date,date_gmt,author_slug, andparent.Breaking:
core/content-querynow filters by and returnsauthor_slug, the user's nicename (theslugthatcore/users-queryreturns), instead of theauthorID filter and theauthor: { id, name }field. The slug is looked up as given, as incore/users-query, so the database collation decides whether a variant in another case matches. A user the caller cannot see incore/users-queryis reported as missing, but anyone who can edit others' posts of the post type can name any author.Breaking: all four abilities take and return the post type as
typeinstead ofpost_type, as in Abilities API: Add acore/content-queryability wordpress-develop#12195. Callers of 1.4.0 that sendpost_type, including through the deprecatedcore/read-contentalias, must switch totype.Input that cannot be applied is rejected with
content_invalid_fieldbefore anything is written, as the query rejects filters withcontent_invalid_filter. This covers:dateanddate_gmtthat refer to different times;Both errors map the failed input to its message under
paramsin the error data, as the REST API'srest_invalid_paramdoes. An update can always send back the post's current parent and dates and, if that user still exists, its current author.Giving a post any public or publicly viewable status needs the publish capability, not only
publish, so a contributor cannot publish through a status another plugin registers. Sending back the status a post already has skips this check.statuslists the same statuses on update as on create, so clients can check it before sending. A post with an internal status, such astrash, keeps it whenstatusis left out; sending that status back fails validation.core/content-queryreturns null instead of an empty string for a date it cannot resolve, as core does, and its date fields declare thedate-timeformat. The write abilities take dates as strings only.Sending back a draft's
date_gmtas the query returned it keeps the draft's floating date. A draft's slug is made unique under the parent it will have. A post created without a status counts as a draft for this.Outputs follow the query's rules: the written post goes through the same
fieldsprojection, which always includes theid(required in the shared post output schema), and raw fields need edit access. Create and update leave out raw fields the user cannot edit instead of refusing them, because the post is already written. Delete returns the trashed post, or withforce, the post as it was before the deletion.The query and the write abilities share the ID lookup, the exposed post type check, the author lookup, the
fieldsinput schema, and the post output schema. Where the query's code differs from core'sWP_Abilities_Content, a// Plugin:comment says what core does. The shared ID lookup accepts an ID in any whole-number form the integer schema accepts, such as"5.0"or"+5", but rejects fractions, and an ID beyond the integer range no longer wraps around onto another post.Update is destructive but not idempotent, so it stays on POST. Delete is destructive and idempotent.
The password, sticky flag, format, featured media, template, menu order, comment and ping status, and terms are left out until
core/content-querycan return them.Related: #1026 (an alternative implementation).
Use of AI Tools
AI assistance: Yes. Claude Code was used to port the review changes from the core PR, including the
typerename.Testing Instructions
core/content-createwith{ "type": "post", "title_raw": "Hello", "status": "draft", "fields": [ "id", "status", "title_raw", "author_slug" ] }.core/content-querywith{ "type": "post", "status": [ "draft" ], "author_slug": "<author_slug from step 2>", "fields": [ "id", "author_slug" ] }. The new draft is in the list.idfrom step 2, runcore/content-updatewith{ "id": <id>, "title_raw": "Hello again" }, thencore/content-deletewith{ "id": <id> }.npm run test:php -- --filter 'ContentTest|ContentCreateTest|ContentUpdateTest|ContentDeleteTest'npm run test:e2e -- tests/e2e/specs/abilitiesChangelog Entry