Roles and Capabilities API

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

This sub-section’s WordPress bibliography lists the reference material consulted while preparing these pages.

WordPress decides what a user may do with capabilities, named permissions such as edit_posts, grouped into roles. Plugins add capabilities for their content, create roles, and refine both at run time with two filters. This page, for plugin developers, builds the Catalogue Manager role of lumen-catalog, restricts Authors' book rights, and checks every rule from PHP, WP-CLI and REST. The site owner’s view is in Users, Roles and Profiles.

This page was written against WordPress 7.1.3 (released 2026-10-06; 7.1.4, a security release of 2026-10-28, is the version to run in production), PHP 7.4 minimum and 8.3+ recommended, and WP-CLI 2.12.0, and checked on 2026-10-28. WordPress 7.2 is in beta and due in December 2026; nothing on this page depends on it. Every output below comes from a 7.1.3 site (official wordpress:7.1.3-php8.4-apache image, MariaDB 11.8, table prefix lp_) running lumen-catalog, first as a single site and then converted to a Multisite network. A disposable site is described in Local Development Environments.

The page builds on Custom Post Types and Taxonomies: lp_book has its own capabilities ('capability_type' => array( 'lp_book', 'lp_books' ), 'map_meta_cap' => true), its taxonomies use manage_lp_terms, activation grants all of them to Administrators and Editors, and that page explains meta versus primitive capabilities.

How roles and capabilities are stored

Roles live in the autoloaded {prefix}user_roles option (lp_user_roles here): each role’s slug, display name and map of capability to true/false, loaded into the global WP_Roles object (wp_roles()). A user’s roles, and any capability granted to that user alone, are in the {prefix}capabilities user meta (lp_capabilities), for example array( 'lp_catalogue_manager' => true ); at run time WP_User merges both into $user->allcaps. On Multisite each site has its own option (lp_2_user_roles, …​) and each user one meta key per site, while super admins are listed in the site_admins network option and are not a role (see Multisite and super admins). The {prefix}user_level meta is a leftover of WordPress 1.5 user levels; never check it. The API:

API Since Purpose

add_role( $role, $display_name, $capabilities )

2.0

Stores a new role. Returns WP_Role, or null if the slug already exists, without touching the stored capabilities

remove_role( $role )

2.0

Deletes the role from the option. Users keep the slug in their meta and end up with no capabilities; if it was the default_role option, that falls back to subscriber

get_role( $role )

2.0

The WP_Role object, or null

WP_Role::add_cap( $cap, $grant = true ), remove_cap()

2.0

Change one capability of a stored role (writes the option)

WP_User::add_cap(), remove_cap(), set_role(), add_role(), remove_role()

2.0

Per-user grants and role assignment (writes user meta)

current_user_can( $cap, ...$args ), user_can( $user, $cap, ...$args )

2.0

Checks, mapping meta capabilities first

current_user_can_for_site( $site_id, $cap, ...$args )

6.7

Switches site and checks; replaces current_user_can_for_blog(), deprecated in 6.7

map_meta_cap( $cap, $user_id, ...$args ) and the map_meta_cap filter

2.0 / 2.8

Translates a meta capability into the primitive capabilities required

user_has_cap filter

2.0

Edits the user’s allcaps for one check

wp_roles_init action

4.7

Fires after roles are loaded; for adjusting role objects in memory only

The Catalogue Manager role

Users, Roles and Profiles describes the role: a sales assistant who manages every book, its cover and its genres and series, with no access to posts, pages, comments, plugins, themes, settings or users. In capabilities, that is read, upload_files, the ten primitive lp_book capabilities and manage_lp_terms, and nothing else; in particular not edit_posts, so the Posts, Pages and Comments menus disappear.

Lumen Press also lets its staff writers (Authors) propose entries for their own titles: Authors get edit_lp_books and delete_lp_books, but not publish_lp_books, edit_others_lp_books or edit_published_lp_books, so core’s mapping already restricts them to their own unpublished books.

Once, on activation, never on init

add_role() writes to the database and ignores a slug that exists. Calling it on every init, as one of the older books does, achieves nothing after the first request and misleads: changing the capability array later changes nothing where the role exists, and deleting the code does not delete the role. (The Plugin Handbook’s short example also uses init, with a caveat.) On 7.1.3:

$ wp eval 'var_dump( add_role( "lp_catalogue_manager", "Catalogue Manager", array( "read" => true, "lp_new_cap" => true ) ) ); var_dump( get_role( "lp_catalogue_manager" )->has_cap( "lp_new_cap" ) );'
NULL
bool(false)

Stored permissions follow the rule for tables and rewrite rules: create on activation, change in a versioned upgrade routine, remove on uninstall. Deactivation keeps them, so users do not lose their role when the plugin is briefly off; meanwhile the lp_book capabilities are inert, since nothing checks them.

src/Roles.php

<?php
/**
 * The Catalogue Manager role and the lp_book capabilities of built-in roles.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/**
 * Creates and removes stored roles and capabilities. Never hooked to init.
 */
final class Roles {

	const ROLE = 'lp_catalogue_manager';

	/**
	 * The ten primitive lp_book capabilities plus manage_lp_terms (literal: uninstall has no post type).
	 *
	 * @return string[]
	 */
	public static function book_caps(): array {
		return array(
			'edit_lp_books',
			'edit_others_lp_books',
			'edit_private_lp_books',
			'edit_published_lp_books',
			'publish_lp_books',
			'read_private_lp_books',
			'delete_lp_books',
			'delete_others_lp_books',
			'delete_private_lp_books',
			'delete_published_lp_books',
			'manage_lp_terms',
		);
	}

	/**
	 * Activation (and upgrade): create the role and grant Authors draft rights. Idempotent.
	 */
	public static function install(): void {
		$caps = array_fill_keys( array_merge( array( 'read', 'upload_files', 'manage_lumen_catalog' ), self::book_caps() ), true );

		$role = get_role( self::ROLE );
		if ( null === $role ) {
			add_role( self::ROLE, 'Catalogue Manager', $caps );
		} else {
			// add_role() ignores an existing role, so a later version adds new capabilities here.
			foreach ( array_keys( $caps ) as $cap ) {
				$role->add_cap( $cap );
			}
		}

		// Administrators also manage the catalogue settings screen.
		$admin = get_role( 'administrator' );
		if ( $admin ) {
			$admin->add_cap( 'manage_lumen_catalog' );
		}

		// Authors may propose books (drafts) but not publish them or touch other people's books.
		$author = get_role( 'author' );
		if ( $author ) {
			$author->add_cap( 'edit_lp_books' );
			$author->add_cap( 'delete_lp_books' );
		}
	}

	/**
	 * Uninstall: move Catalogue Managers to Subscriber, drop the role, every lp_book capability and manage_lumen_catalog.
	 */
	public static function uninstall(): void {
		foreach ( get_users( array( 'role' => self::ROLE ) ) as $user ) {
			$user->set_role( 'subscriber' );
		}
		remove_role( self::ROLE );

		foreach ( wp_roles()->role_objects as $role ) {
			foreach ( array_merge( self::book_caps(), array( 'manage_lumen_catalog' ) ) as $cap ) {
				if ( $role->has_cap( $cap ) ) {
					$role->remove_cap( $cap );
				}
			}
		}
	}

	/**
	 * Multisite: a site created while the plugin is network-active gets the role too.
	 * Hooked to wp_initialize_site after core (priority 10) has created the site's tables.
	 *
	 * @param \WP_Site $site The new site.
	 */
	public static function install_on_new_site( \WP_Site $site ): void {
		if ( ! function_exists( 'is_plugin_active_for_network' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}
		if ( ! is_plugin_active_for_network( plugin_basename( LUMEN_CATALOG_FILE ) ) ) {
			return;
		}
		switch_to_blog( $site->id );
		Installer::grant_capabilities();
		self::install();
		restore_current_blog();
	}
}

The slug carries the plugin prefix. The display name is stored untranslated: translate_user_role() translates role names at display time (with a text domain argument since 5.2), whereas a translated string at creation would freeze one language in the database. The capability list is literal because uninstall.php runs without the post type registered. Upgrading an existing role uses add_cap(), since add_role() ignores it. Uninstall moves Catalogue Managers to Subscriber first, because remove_role() would leave them with a dangling slug and no capabilities, not even read. Built-in roles are only extended, never redefined, and themes create no roles at all: roles outlive a theme switch.

Wiring it into the life cycle

Two calls connect the class to the life cycle of Plugin Fundamentals: Installer::activate() calls Roles::install() right after self::grant_capabilities() (the post type and taxonomies are registered by then), and Installer::uninstall_site() calls Roles::uninstall() before it deletes the options. Plugin::boot() hooks the rest of this page: Capabilities::register(), add_action( 'wp_initialize_site', array( Roles::class, 'install_on_new_site' ), 900 ) and add_action( 'rest_api_init', array( StockController::class, 'register_routes' ) ).

Activation hooks do not run when a plugin is updated. A release that introduces or changes the role therefore also bumps DB_VERSION and calls Roles::install() in the matching step of Installer::upgrade() (for example if ( $installed < 3 ) { Roles::install(); }), which runs on the first request after the update. install() is idempotent, so running it from both places is harmless.

Checking the result with WP-CLI

$ wp role list
name	role
Administrator	administrator
Editor	editor
Author	author
Contributor	contributor
Subscriber	subscriber
Catalogue Manager	lp_catalogue_manager
$ wp cap list lp_catalogue_manager
read
upload_files
manage_lumen_catalog
edit_lp_books
edit_others_lp_books
edit_private_lp_books
edit_published_lp_books
publish_lp_books
read_private_lp_books
delete_lp_books
delete_others_lp_books
delete_private_lp_books
delete_published_lp_books
manage_lp_terms

The test users are admin (Administrator, ID 1), maria.editor (Editor, 2), tom.author (Author, 3), lena.catalogue (given the role with wp user set-role, 4) and sam.reader (Subscriber, 5). Logged in as lena.catalogue, the admin menu has the Catalogue menu (with Genres and Series) and no Posts or Pages menu; edit.php, edit.php?post_type=page and options-general.php answer 403 with "Sorry, you are not allowed to access this page", while the book list, the block editor for books and the Genres screen load.

wp role create, wp role delete, wp cap add and wp cap remove change the same option, and wp role reset restores built-in roles to their defaults. They suit staging fixes; a role the plugin’s code relies on belongs in the plugin, so that every environment gets it the same way.

Refining permissions with map_meta_cap

Roles hold primitive capabilities only (granting edit_lp_book to a role has no effect, since it is always mapped). Code calls current_user_can( 'edit_lp_book', $book_id ), and map_meta_cap() decides which primitive capabilities that book requires. For tom.author and three books, The Glass Orchard (5, published, by lena.catalogue), Salt Roads (6, draft, by tom.author) and Night Ferry (7, pending review, by tom.author):

$ wp eval 'foreach ( array( 5, 6, 7 ) as $id ) { echo $id, ": ", implode( ", ", map_meta_cap( "edit_lp_book", 3, $id ) ), "\n"; }'
5: edit_others_lp_books, edit_published_lp_books
6: edit_lp_books
7: edit_lp_books, edit_others_lp_books

Core’s mapping already gives "edit only own books": someone else’s book requires edit_others_lp_books, and a published own book requires edit_published_lp_books, neither of which Authors have. Book 7 shows a rule core cannot express and the plugin adds: once an Author submits a book for review, it is locked for them until an Editor or Catalogue Manager publishes or returns it. Without the filter, core would ask only for edit_lp_books for an own pending post.

The filter receives the primitive capabilities core computed and returns a new list: adding do_not_allow denies everyone, super admins included; adding a capability denies those who lack it.

<?php
/**
 * Runtime capability rules for books.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/**
 * Filters that refine what the stored capabilities allow.
 */
final class Capabilities {

	public static function register(): void {
		add_filter( 'map_meta_cap', array( self::class, 'lock_submitted_books' ), 10, 4 );
		add_filter( 'user_has_cap', array( self::class, 'freeze_catalogue' ), 10, 4 );
	}

	/**
	 * Authors edit only their own books, and only until they submit them for review.
	 *
	 * @param string[] $caps    Primitive capabilities core requires.
	 * @param string   $cap     Capability checked: edit_post, not edit_lp_book (see below).
	 * @param int      $user_id User being checked.
	 * @param array    $args    Context; $args[0] is the post ID.
	 */
	public static function lock_submitted_books( $caps, $cap, $user_id, $args ) {
		if ( ! in_array( $cap, array( 'edit_post', 'delete_post' ), true ) || empty( $args[0] ) ) {
			return $caps;
		}

		$post = get_post( (int) $args[0] );
		if ( ! $post || 'lp_book' !== $post->post_type || 'pending' !== $post->post_status ) {
			return $caps;
		}

		// Core asked for edit_lp_books (own pending book); require the reviewer's capability instead.
		if ( (int) $post->post_author === (int) $user_id ) {
			$caps[] = 'edit_others_lp_books';
		}

		return $caps;
	}

	/**
	 * While the catalogue is frozen for stock-taking, only administrators change books.
	 *
	 * @param bool[]   $allcaps Capabilities the user has (role and user grants combined).
	 * @param string[] $caps    Primitive capabilities being checked.
	 * @param array    $args    Requested capability, user ID, then context.
	 * @param \WP_User $user    The user.
	 */
	public static function freeze_catalogue( $allcaps, $caps, $args, $user ) {
		$settings = (array) get_option( 'lp_catalog_settings', array() );
		if ( empty( $settings['catalogue_frozen'] ) || ! empty( $allcaps['manage_options'] ) ) {
			return $allcaps;
		}

		foreach ( Roles::book_caps() as $book_cap ) {
			if ( 'manage_lp_terms' !== $book_cap ) {
				$allcaps[ $book_cap ] = false;
			}
		}

		return $allcaps;
	}
}

The filter tests edit_post, not edit_lp_book: for a custom post type, map_meta_cap() looks the name up in the $post_type_meta_caps global and calls itself with the generic name (edit_post, delete_post, read_post), and only that inner call reaches the filter. It must also check the post type, since it sees every post, and stay cheap: it runs on every capability check, often hundreds of times per admin screen.

The metadata field lp_isbn in Metadata and Custom Fields restricts edits in Meta::can_edit_isbn(), which checks the book capability edit_others_lp_books rather than edit_others_posts; that is why a Catalogue Manager, who has no edit_others_posts, can still change ISBNs.

Granting or denying at run time with user_has_cap

user_has_cap runs after the mapping and returns a modified allcaps array for one check; nothing is stored. It suits rules that depend on state (a time window, a setting, a subscription). Capabilities::freeze_catalogue() above removes every book capability (except term management) from everyone without manage_options while the catalogue_frozen setting of the lp_catalog_settings option is on, for a stock-taking weekend.

Logic about one object fits map_meta_cap better. A grant made with user_has_cap is invisible to wp cap list, the Users screen and role editors, so document it.

Checking capabilities in PHP

Always check a capability, and for one object pass its ID so the meta capability is mapped:

Write Not

current_user_can( 'edit_lp_book', $book_id )

current_user_can( 'edit_lp_books' ) before editing book $book_id (ignores ownership and status)

current_user_can( 'manage_lp_terms' )

current_user_can( 'editor' ) or in_array( 'editor', $user->roles, true ) (role checks are partly supported, unreliable, and break the Catalogue Manager)

user_can( $user_id, 'publish_lp_books' ) for another user (cron, CLI, notifications)

current_user_can() in a cron callback, where no user is logged in

current_user_can_for_site( $site_id, 'edit_lp_book', $id )

current_user_can_for_blog() (deprecated in 6.7)

is_admin() only to ask "is an admin screen being rendered"

is_admin() as a permission check: it is true for every request to admin-ajax.php

a nonce and a capability check

a nonce alone: nonces prove intent, not permission

Checking edit_post (or edit_lp_book) without an object ID is reported with _doing_it_wrong since 6.1 and denied. The nonce, sanitising and escaping side of the same checks is on Security for Developers.

The resulting matrix, printed with user_can() for every test user:

<?php
// wp eval-file matrix.php: one row per check, one column per user.
$logins = array( 'admin', 'maria.editor', 'tom.author', 'lena.catalogue', 'sam.reader' );
$checks = array( array( 'edit_lp_books' ), array( 'publish_lp_books' ), array( 'edit_lp_book', 5 ), array( 'edit_lp_book', 6 ),
	array( 'edit_lp_book', 7 ), array( 'delete_lp_book', 6 ), array( 'manage_lp_terms' ), array( 'edit_posts' ) );

vprintf( '%-20s' . str_repeat( '%-16s', 5 ) . "\n", array_merge( array( 'capability' ), $logins ) );
foreach ( $checks as $check ) {
	printf( '%-20s', implode( ' ', $check ) );
	foreach ( $logins as $login ) {
		printf( '%-16s', user_can( get_user_by( 'login', $login ), ...$check ) ? 'yes' : 'no' );
	}
	echo "\n";
}
capability          admin           maria.editor    tom.author      lena.catalogue  sam.reader
edit_lp_books       yes             yes             yes             yes             no
publish_lp_books    yes             yes             no              yes             no
edit_lp_book 5      yes             yes             no              yes             no
edit_lp_book 6      yes             yes             yes             yes             no
edit_lp_book 7      yes             yes             no              yes             no
delete_lp_book 6    yes             yes             yes             yes             no
manage_lp_terms     yes             yes             no              yes             no
edit_posts          yes             yes             yes             no              no

With wp option update lp_catalog_settings '{"catalogue_frozen":true}' --format=json the same script prints no for every book row except under admin, while manage_lp_terms and edit_posts are unchanged.

Capabilities in the REST API

The core endpoints of a post type check its capabilities themselves: /wp/v2/lp_book uses create_posts (here edit_lp_books) to create, publish_lp_books for a published status, and the edit_lp_book meta capability to update, so both filters apply to REST and to the block editor without extra code. Custom routes must say who may call them in permission_callback (mandatory since 5.5; omitting it triggers a _doing_it_wrong notice and leaves the route public). The lumen/v1 stock route lets anyone who may edit a book correct its stock count:

<?php
/**
 * REST route lumen/v1/books/<id>/stock.
 *
 * @package Lumen\Catalog
 */

namespace Lumen\Catalog;

defined( 'ABSPATH' ) || exit;

/**
 * Lets people who may edit a book correct its stock count.
 */
final class StockController {

	public static function register_routes(): void {
		register_rest_route(
			'lumen/v1',
			'/books/(?P<id>\d+)/stock',
			array(
				'methods'             => 'PUT',
				'callback'            => array( self::class, 'update' ),
				'permission_callback' => array( self::class, 'can_update' ),
				'args'                => array(
					'id'       => array( 'type' => 'integer', 'required' => true ),
					'quantity' => array( 'type' => 'integer', 'minimum' => 0, 'required' => true ),
				),
			)
		);
	}

	/**
	 * The meta capability, checked against this book: ownership, status and filters all apply.
	 *
	 * @param \WP_REST_Request $request Request.
	 * @return true|\WP_Error
	 */
	public static function can_update( \WP_REST_Request $request ) {
		$book = get_post( (int) $request['id'] );
		if ( ! $book || 'lp_book' !== $book->post_type ) {
			return new \WP_Error( 'lp_book_not_found', __( 'No such book.', 'lumen-catalog' ), array( 'status' => 404 ) );
		}
		if ( ! current_user_can( 'edit_lp_book', $book->ID ) ) {
			return new \WP_Error(
				'lp_cannot_update_stock',
				__( 'Sorry, you are not allowed to change the stock of this book.', 'lumen-catalog' ),
				array( 'status' => rest_authorization_required_code() )
			);
		}
		return true;
	}

	/**
	 * Stores the new quantity in the lp_stock post meta and records the change in the stock history.
	 *
	 * @param \WP_REST_Request $request Request.
	 */
	public static function update( \WP_REST_Request $request ) {
		global $wpdb;
		$book_id  = (int) $request['id'];
		$quantity = (int) $request['quantity'];
		$previous = (int) get_post_meta( $book_id, 'lp_stock', true );

		update_post_meta( $book_id, 'lp_stock', $quantity );
		$row = array( 'book_id' => $book_id, 'delta' => $quantity - $previous, 'quantity' => $quantity, 'changed_at' => current_time( 'mysql', true ) );
		$wpdb->insert( $wpdb->prefix . 'lp_stock', $row, array( '%d', '%d', '%d', '%s' ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
		return rest_ensure_response( array( 'book' => $book_id, 'quantity' => $quantity ) );
	}
}

rest_authorization_required_code() gives 401 to anonymous callers and 403 to logged-in users. Each test user has an application password ($LENA_APP_PASSWORD, …​; the site’s WP_ENVIRONMENT_TYPE is local, which allows them over HTTP), and each request has the form curl -s -u "lena.catalogue:$LENA_APP_PASSWORD" -X PUT -H 'Content-Type: application/json' "$SITE/wp-json/lumen/v1/books/5/stock" -d '{"quantity":40}' (see REST API Handbook: Authentication):

user            method route                            status code or result
maria.editor    POST   /wp/v2/lp_book (draft)            201    id=8 status=draft
tom.author      POST   /wp/v2/lp_book (draft)            201    id=9 status=draft
lena.catalogue  POST   /wp/v2/lp_book (draft)            201    id=10 status=draft
sam.reader      POST   /wp/v2/lp_book (draft)            403    rest_cannot_create
tom.author      POST   /wp/v2/lp_book (publish)          403    rest_cannot_publish
tom.author      POST   /wp/v2/lp_book/5                  403    rest_cannot_edit
lena.catalogue  POST   /wp/v2/lp_book/5                  200    id=5 status=publish
tom.author      POST   /wp/v2/lp_book/6                  200    id=6 status=draft
tom.author      POST   /wp/v2/lp_book/7                  403    rest_cannot_edit
lena.catalogue  POST   /wp/v2/posts (draft)              403    rest_cannot_create
tom.author      PUT    /lumen/v1/books/5/stock           403    lp_cannot_update_stock
lena.catalogue  PUT    /lumen/v1/books/5/stock           200    {"book":5,"quantity":40}

The 403 on book 7 is the map_meta_cap filter at work; the 403 on /wp/v2/posts shows the Catalogue Manager confined to books. With the catalogue frozen, the three draft creations by maria.editor, tom.author and lena.catalogue return 403 rest_cannot_create as well.

Multisite and super admins

Roles are per site on a network, so a user can be an Editor on one site and a Catalogue Manager on another. For a plugin this means:

  • Network activation runs the activation callback once, on the main site, with $network_wide true, so the role exists only there. Loop over get_sites() with switch_to_blog() (slow on large networks), or create the role in the per-site upgrade routine, which runs on each site’s first request.

  • Sites created later need the role too: Roles::install_on_new_site() hooks wp_initialize_site at priority 900, after core (priority 10) has created the new site’s tables and default roles.

  • Uninstall runs once for the network; lumen-catalog's uninstall.php already loops over the sites and calls Installer::uninstall_site(), and therefore Roles::uninstall(), on each.

On the test network, with the plugin first active on the main site only, Lumen Kids created at that point, then the plugin network-activated and Lumen Noir created afterwards:

$ wp role exists lp_catalogue_manager --url=http://lumenpress.test
Success: Role with ID 'lp_catalogue_manager' exists.
$ wp role exists lp_catalogue_manager --url=http://lumenpress.test/kids
Error: Role with ID 'lp_catalogue_manager' does not exist.
$ wp role exists lp_catalogue_manager --url=http://lumenpress.test/noir
Success: Role with ID 'lp_catalogue_manager' exists.

Super admins are the logins in the site_admins network option (grant_super_admin(), revoke_super_admin(), wp super-admin add). WP_User::has_cap() returns true for them on every site for any capability, even an unknown one, unless the mapped capabilities contain do_not_allow; the user_has_cap filter is not called for them. Only super admins pass the network capabilities (manage_network, manage_network_plugins, …​), and on Multisite core maps unfiltered_html, delete_users, update_core and plugin installation to super admins only, so a site Administrator lacks them. With maria.editor made a super admin and the catalogue frozen:

$ wp super-admin add maria.editor
Success: Granted super-admin capabilities to 1 user.
$ wp eval '$maria = get_user_by( "login", "maria.editor" ); foreach ( array( array( "edit_lp_book", 7 ), array( "edit_lp_book", 999 ), array( "manage_network_plugins" ), array( "lumen_anything" ) ) as $c ) { printf( "%-26s %s\n", implode( " ", $c ), user_can( $maria, ...$c ) ? "yes" : "no" ); }'
edit_lp_book 7             yes
edit_lp_book 999           no
manage_network_plugins     yes
lumen_anything             yes

The freeze did not stop her, and an undefined capability returns yes; only book 999, which does not exist, was mapped to do_not_allow. A rule that must bind super admins belongs in map_meta_cap with do_not_allow. Do not use is_super_admin() as a permission check: on a single site it is true for anyone with delete_users.

Uninstalling from the network

After wp plugin deactivate lumen-catalog --network and wp plugin uninstall lumen-catalog, the sites had lost the role and the book capabilities, and the Catalogue Manager had become a Subscriber:

$ wp role exists lp_catalogue_manager
Error: Role with ID 'lp_catalogue_manager' does not exist.
$ wp user get lena.catalogue --field=roles
subscriber
$ wp cap list editor | grep -c lp_
0
$ wp role exists lp_catalogue_manager --url=http://lumenpress.test/noir
Error: Role with ID 'lp_catalogue_manager' does not exist.

Where older books are outdated

Older claim Current behaviour (WordPress 7.1)

Register a custom role with add_role() on every init

The role is stored on the first call and every later call returns null without applying changes. Create it once on activation, update it in a versioned upgrade routine, remove it on uninstall

Restrict an admin feature with is_admin() or a role name

is_admin() reports the screen, not the user; check a capability with current_user_can()

current_user_can_for_blog() checks a capability on another site

Deprecated in 6.7 in favour of current_user_can_for_site()