Skip to content

Knowledge: Dissolve the Guidelines singleton into per-scope rows#79263

Draft
aagam-shah wants to merge 4 commits into
WordPress:update/rename-guideline-cpt-to-knowledgefrom
aagam-shah:update/rename-guideline-cpt-to-knowledge
Draft

Knowledge: Dissolve the Guidelines singleton into per-scope rows#79263
aagam-shah wants to merge 4 commits into
WordPress:update/rename-guideline-cpt-to-knowledgefrom
aagam-shah:update/rename-guideline-cpt-to-knowledge

Conversation

@aagam-shah

@aagam-shah aagam-shah commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

What?

Follow up to #79149. Part of the Guidelines/Knowledge effort (#77230).

Dissolves the Settings → Guidelines singleton-plus-meta model into one guideline-typed wp_knowledge row per scope (content stored in post_content), driven entirely through the standard /wp/v2/knowledge entity. Both specialized REST controllers (~1,150 lines) and the hand-rolled client store/api are removed. The visible Settings → Guidelines UI is unchanged.

Stacked on #79149 — this PR's diff is the three commits on top of that branch and should be reviewed after it. It will retarget to trunk automatically when #79149 merges.

Why?

The old screen rested on special-purpose machinery: a single wp_knowledge post holding every category as _guideline_* meta, a dedicated /wp/v2/content-guidelines controller, a custom revisions controller, and a bespoke client API/store, with the categories hardcoded in the UI. Re-using the wp_knowledge primitive's own building blocks removes that machinery, gives each scope a real row (so per-row revision history later comes free from the default endpoint), and makes the data model addressable and extensible via a filter instead of hardcoded lists.

How?

  • Per-scope rows + identity. Each scope is backed by at most one guideline-typed row addressed by a guideline- slug prefix (guideline-copy, guideline-block-core_paragraph). The canonical block name is stored in the row title; the namespace separator is encoded as _ so distinct block names can't collide on one slug.
  • Scope registry. A new wp_guideline_scopes filter is the source of truth for the Settings sections (plugins can add sections); a small read-only /wp/v2/knowledge/guideline-scopes controller exposes it (gated on read_knowledge, the /wp/v2/statuses pattern), preloaded on the page.
  • Reservation guard. A guard on the REST insert path forces the guideline term onto prefixed slugs, keeps them unique (409 on a duplicate, on create and update) and verbatim (no -2 suffix), sanitizes post_content to plain text capped at 5000 chars, and re-stamps registry scope titles in the site locale.
  • Type term rename. The wp_knowledge_type built-in term instruction becomes guideline; the upgrade migration converges content and the interim instruction onto guideline.
  • Client. routes/guidelines now reads/writes through @wordpress/core-data (useEntityRecords + a runtime guidelineScope entity); sections render from the registry; revision history is hidden; import/export keeps the same JSON shape so existing files round-trip.
  • Removals. Deletes class-gutenberg-content-guidelines-rest-controller.php, class-gutenberg-content-guidelines-revisions-controller.php, the guideline meta registration/helpers, and the client store.ts / api.ts / revision-history.tsx.

Note: existing singleton-meta data is not migrated (the feature is experimental and flag-gated); see discussion for whether a one-time migration is warranted.

Testing Instructions

  1. Enable the Guidelines experiment (Gutenberg → Experiments, or set the gutenberg-experiments option to {"gutenberg-guidelines":"1"}).
  2. Go to Settings → Guidelines. Confirm the Site, Copy, Images, Blocks, and Additional sections render and the Actions card shows Import/Export (no Revert/revision-history UI).
  3. Edit and Save a scope (e.g. Copy); reload and confirm it persists. Clear it and confirm it's removed after reload.
  4. In Blocks, Add a guideline for a block, Edit it, then Remove it.
  5. Export to JSON, then Import the file back and confirm the guidelines are restored.
  6. In DevTools → Network, confirm requests hit /wp/v2/knowledge and /wp/v2/knowledge/guideline-scopes (and never /wp/v2/content-guidelines).

Automated:

  • npm run test:unit:php -- --group knowledge (PHPUnit, incl. the scopes controller and reservation-guard tests).
  • npm run test:e2e -- test/e2e/specs/admin/guidelines.spec.js.

Testing Instructions for Keyboard

Tab to Settings → Guidelines, expand a section with Enter/Space, Tab into its textarea, type, and Tab to Save guidelines / Clear guidelines. For Blocks, Tab to Add guidelines, operate the block combobox and guideline textarea, and reach the modal's Save/Remove with Tab; confirm focus returns to the page on close.

Screenshots or screencast

No visual change — the Settings → Guidelines UI is intentionally identical; only the backend storage and client data layer changed.

Use of AI Tools

This PR was authored with the assistance of Claude Code (Anthropic, Claude Opus 4.8). AI was used for the implementation, the PHPUnit/e2e tests, and this description; all changes were reviewed and verified by a human (PHPUnit --group knowledge, the e2e spec, PHPCS, and a manual browser pass), and the author takes responsibility for the contents per the WordPress AI Guidelines.

🤖 Generated with Claude Code

@gziolo gziolo force-pushed the update/rename-guideline-cpt-to-knowledge branch 2 times, most recently from 1f20bdf to 9a4a5c7 Compare June 18, 2026 10:39
aagam-shah and others added 4 commits June 18, 2026 21:57
Replace the content-guidelines singleton + meta model with one
`guideline`-typed `wp_knowledge` row per scope (content in post_content),
addressed by a `guideline-` slug prefix.

- Rename the `wp_knowledge_type` term `instruction` -> `guideline`
  (wp_knowledge_types(), TERM_GUIDELINE, and the upgrade migration, which
  now converges both `content` and the interim `instruction` onto
  `guideline`).
- Add the `wp_guideline_scopes` registry filter and a read-only
  `/wp/v2/knowledge/guideline-scopes` controller; preload it on the
  Settings page.
- Enforce identity with a reservation guard (force the guideline term,
  keep slugs unique with no suffix, reject duplicate creates) and a save
  filter (sanitize content + cap length, re-stamp scope titles in the
  site locale).
- Delete both specialized controllers and the meta machinery; data flows
  through the standard /wp/v2/knowledge collection.
- Drive the Settings UI through @wordpress/core-data (useEntityRecords +
  a runtime guidelineScope entity); delete the hand-rolled store/api.
- Render sections from the registry, hide revision history, and keep the
  import/export JSON shape unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Importing onto (or editing) a guideline row that was loaded via the
collection request after a reload threw "Cannot read properties of
undefined (reading 'content')".

The `wp_knowledge` (postType) entity already fetches with `context: 'edit'`
via its `baseURLParams`, so the collection response includes raw field
values regardless of the query. Passing `context: 'edit'` in the query as
well only changed the *storage bucket* to `edit`, where
`editEntityRecord`/`getRawEntityRecord` (which read the `default` bucket)
could not find the row — so `editEntityRecord` dereferenced `undefined`.

- Drop `context: 'edit'` from the collection query; raw content still
  arrives via the entity baseURLParams, and rows now land in the `default`
  bucket where edits resolve.
- Pass options to `saveEditedEntityRecord` in the correct argument
  position so `throwOnError` is honored on updates.
- Gate the loading spinner on `hasResolved` rather than `isResolving` so
  the form doesn't mount with empty content and clobber freshly-typed
  text when the rows arrive.
- Add an e2e regression test that edits a scope guideline after a reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…update

Two correctness fixes for the guideline-row identity model.

- Block slugs were lossy: deriving `guideline-block-<ns>-<name>` by
  replacing `/` with `-` collapsed distinct block names such as
  `foo/bar-baz` and `foo-bar/baz` onto the same slug, so one block's
  guideline could overwrite another's. Encode the namespace separator as
  `_` instead — block names match `[a-z0-9-]+/[a-z0-9-]+` and never
  contain `_`, so the mapping is injective. The canonical block name still
  lives in the row title.

- The reservation guard only checked slug uniqueness on create, so a REST
  update could repoint an existing row's slug onto an already-used
  `guideline-` slug, producing duplicate-identity rows. Run the check on
  update too, excluding the row itself so content-only saves still pass.

Adds reservation tests for the update-collision and content-only-update
cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The guidelines route's package.json dropped @wordpress/api-fetch and
@wordpress/date and added @wordpress/core-data; regenerate the lockfile so
the check-local-changes CI gate passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@aagam-shah aagam-shah force-pushed the update/rename-guideline-cpt-to-knowledge branch from 63d105f to 18e953e Compare June 18, 2026 16:30
@aagam-shah aagam-shah marked this pull request as ready for review June 18, 2026 16:35
@aagam-shah aagam-shah marked this pull request as draft June 18, 2026 16:35
@github-actions

Copy link
Copy Markdown

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 props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: aagam-shah <aagam94@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@gziolo gziolo added the [Feature] Guidelines An experimental feature for adding site-wide editorial rules. label Jun 19, 2026
@gziolo gziolo added the [Type] Breaking Change For PRs that introduce a change that will break existing functionality label Jun 19, 2026
'description' => __( 'Set your writing standards for tone, voice, style, and formatting.', 'gutenberg' ),
'order' => 20,
),
'images' => array(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's outside of this PR, but would it be helpful to also have generic guidelines for blocks used as a fallback if something specific isn't defined?

public function get_items_permissions_check( $request ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! current_user_can( 'read_knowledge_items' ) ) {
return new WP_Error(
'rest_forbidden',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (consistency, non-blocking): this returns rest_forbidden, but after #79149 the sibling /wp/v2/knowledge collection read now returns rest_cannot_read. Since this endpoint is explicitly modeled on /wp/v2/statuses (which itself uses rest_cannot_view) and sits right beside the knowledge collection, it'd be nice to align the error code so REST clients get a consistent code across the knowledge routes — either rest_cannot_read to match the collection, or rest_cannot_view to match the /wp/v2/statuses precedent.

Comment on lines +416 to +426
$existing_ids = get_posts(
array(
'post_type' => Gutenberg_Knowledge_Post_Type::POST_TYPE,
'name' => $slug,
'post_status' => 'any',
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'post__not_in' => empty( $prepared_post->ID ) ? array() : array( (int) $prepared_post->ID ),
)
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (edge case, non-blocking): this uniqueness query uses 'post_status' => 'any', which excludes trash (and auto-draft). So if a guideline- row is trashed rather than force-deleted, its slug frees up — a new row can claim it, and restoring the trashed row then leaves two rows sharing the same guideline- slug (which is identity here). The Settings UI always force-deletes, so this doesn't surface today; it'd only bite if a row is trashed via another path (WP-CLI, future UI, another plugin). Consider including trash in the status list (e.g. array( 'publish', 'draft', 'pending', 'private', 'future', 'trash' )) or documenting the force-delete assumption.

@gziolo

gziolo commented Jun 19, 2026

Copy link
Copy Markdown
Member

Suggestion: reclaim the row instead of blocking the save — and enforce it for every insert path, not only REST.

Creating a guideline with an existing guideline- slug currently returns a 409 error. The slug must be unique (one row per scope), so a conflict usually means the row was trashed earlier, or it was created at the same time in another session. Both are edge cases.

Instead of blocking the save, we can reclaim the existing row:

  1. Find the row by slug, across all statuses (including trash).
  2. Restore it if it is in the trash.
  3. Overwrite its fields as if it was just created.
  4. Return it as the result.

This is safe to do automatically because the row has both the guideline term and the guideline- slug prefix. That pair clearly marks the row as part of the guidelines contract, so we are not touching an unrelated post. We should check both before reclaiming.

One more point: I would put this guard outside the REST path — a shared helper, or a hook that runs on every insert — not only in rest_pre_insert. Right now the slug is kept verbatim on all paths, but uniqueness is checked only during REST requests. A general-purpose guard keeps the "one row per slug" rule true for any caller (REST, wp_insert_post, WP-CLI) and removes the gap between the two checks. It also makes the related trash edge case (see the inline thread on the uniqueness query) go away, because a taken slug becomes an update instead of a second insert.

@gziolo

gziolo commented Jun 19, 2026

Copy link
Copy Markdown
Member

After more research, I think the rule can be smaller. We may only need one public row per slug, instead of preventing duplicate slugs everywhere.

If the Settings page trusts only public guideline-* rows, the rule gets simpler and safer.

  • The status tells us which row is the real one. The real row is the public one. The page reads and writes only the status it owns (publish). A private or trashed row with the same slug is just a placeholder, not the real row. The page already asks only for publish rows, so it never sees private ones.

  • When an admin saves, reuse the old row instead of showing an error. On save, look for the slug in every status. If a public row already exists and the user can edit it, update it. If only a private or trashed row exists, reuse it: restore it, publish it, and replace its content, changing the author to respect permissions. Create a new row only when nothing is found. So there is no 409 error on the first save.

  • Capabilities already protect the real row. Non-admins cannot publish, and they cannot edit other people's rows. New rows are private by default. So a non-admin can never create or change the public guideline row. If the reuse step goes through the normal capability checks (publish_knowledge_items and edit_others_knowledge_items), this works on its own. Taking over someone else's private row is an "edit others and publish" action, and only admins can do that.

  • A later private row should not reuse the public slug. A user can still create a private row after the public guideline row exists. That is fine. But the private row should not take the same exact slug. Keep the exact slug only for the real public row. For every other case, let the normal WordPress logic make the slug unique (for example, guideline-copy-2). This way two rows never share the same slug, and the public row keeps its stable, predictable address.

Two nice results:

  • Because an admin save reuses the old row instead of adding a second one, WordPress never has to change the public row's slug. The trash edge case also goes away.
  • A private or draft row can no longer block the admin's first save.

@aagam-shah

Copy link
Copy Markdown
Contributor Author

Thanks for the review @gziolo, this is a great suggestion - the "one public row per slug, reclaim on save" model is cleaner than the 409, and it removes both the trash edge case and the private-row-blocking concern. I'm on board with the direction. A few things to pin down before I implement:

  1. Scope of the guard. I'd split it in two:

    • (a) the slug rule - only the published row keeps the exact guideline- slug; any other row gets uniquified (guideline-copy-2). I can enforce this for every caller via wp_unique_post_slug.
    • (b) the reclaim - turning a "create" into a restore/overwrite of the existing row. This is clean in our save flow, but hard for a raw wp_insert_post() / WP-CLI insert, since there's no tidy hook to turn an insert into an update.

    Is (a) for everyone + (b) in the guidelines save flow OK, or do you want the reclaim on every insert path too?

  2. Author on reclaim: I'll switch the row's author only when a save takes over another user's row - not on a normal self-owned save. Just curious, is this kinda flow trivial or will that confuse the user?

  3. Editing a row's slug. The current guard also blocks an update from repointing a row's slug onto the published slug. Keep that, drop it (let WP uniquify), or treat changing a guideline row's slug as unsupported?

Also confirming an assumption: scope rows are only ever published (no draft state), so "the public row" = the published row. Let me know if that's off.

I'll also fix the rest_forbiddenrest_cannot_read nit to match the sibling collection.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Feature] Guidelines An experimental feature for adding site-wide editorial rules. [Type] Breaking Change For PRs that introduce a change that will break existing functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants