Skip to content

Register Stream operations as WordPress Abilities#1859

Open
PatelUtkarsh wants to merge 31 commits into
developfrom
feature/XWPENG-13-abilities-api
Open

Register Stream operations as WordPress Abilities#1859
PatelUtkarsh wants to merge 31 commits into
developfrom
feature/XWPENG-13-abilities-api

Conversation

@PatelUtkarsh
Copy link
Copy Markdown
Member

@PatelUtkarsh PatelUtkarsh commented May 4, 2026

Resolves XWPENG-13.

Integrates Stream with the WordPress 6.9 Abilities API. Eleven Stream operations are registered as abilities under the stream/ namespace, exposed via the core Abilities REST controller at /wp-abilities/v1/abilities/stream/{slug}/run once a site owner explicitly opts in.

Approach

Mirrors Stream's existing connector/alert plugin extension layout: an abstract base class, a loader gated on environment + opt-in setting, and one file per ability that delegates to existing Stream internals (no business logic duplicated).

Permission model — read abilities use Stream's view_stream capability (via a shared trait) so editors and other roles configured under "Role Access" can call them, matching the admin UI. Write and destructive abilities require the Stream settings capability (WP_STREAM_SETTINGS_CAPABILITY, filterable; defaults to manage_options).

Schemas are hand-written with AI-readable description strings rather than auto-derived, decoupling the public API from internal arg names.

Abilities registered:

Ability Permission Backed by
stream/get-records view_stream Stream records query
stream/get-record view_stream Single record + meta
stream/get-settings settings cap Stream settings option
stream/get-alerts view_stream wp_stream_alerts posts + meta
stream/get-connectors view_stream Registered connectors + labels
stream/get-exclusion-rules view_stream Pivoted exclusion-rule rows
stream/create-alert settings cap New wp_stream_alerts post
stream/update-settings settings cap Partial-merge option update
stream/create-exclusion-rule settings cap Append to exclusion-rule columns
stream/purge-records settings cap Filtered cascade delete
stream/delete-alert settings cap Hard-delete wp_stream_alerts post

Routing follows the WP core Abilities REST controller defaults: read-only abilities are served as GET, write abilities as POST, and destructive + idempotent abilities as DELETE. Non-matching methods return 405 Method Not Allowed.

Gating

  1. WordPress version — silent no-op on WP < 6.9. The loader returns early in its constructor, the settings field is hidden in the Advanced section, and the abstract base's register() checks function_exists( 'wp_register_ability' ) defensively. No admin notice is shown (per direction).
  2. Settings toggle — new advanced_enable_abilities_api checkbox in the existing Advanced section of Stream settings. Defaults to 0. Only renders on WP 6.9+. Honors network option on network-activated multisite.

Safety

  • Read abilities use view_stream; write/destructive abilities use the Stream settings capability (filterable).
  • stream/purge-records requires confirm: true AND at least one filter (older_than_days, connector, context, action); a confirm-only payload returns a 400 WP_Error rather than truncating the table. On any multisite request that is not running inside Network Admin (REST never is), the purge is scoped to blog_id = get_current_blog_id() so it cannot wipe other sites' records.
  • stream/get-record applies the same per-blog scoping so a view_stream user on one site can't read records from other sites by guessing IDs.
  • stream/delete-alert is idempotent — the second call returns the same 404 WP_Error as a missing ID. Refuses to delete posts that are not of wp_stream_alerts type.
  • stream/create-alert validates alert_type against registered alert types and rejects unknown types.
  • stream/get-records schema tightened: orderby is an enum, *__in arrays have maxItems caps.
  • stream/create-exclusion-rule validates IP format at the application layer.
  • older_than_days cutoffs are computed as a UTC DateTime in PHP (matching Admin::purge_scheduled_action()) so behavior is independent of MySQL server timezone.

Manual REST QA

After merging and enabling the toggle on a WP 6.9+ site:

# List the eleven abilities.
curl -u admin:password 'http://stream.wpenv.net/wp-json/wp-abilities/v1/abilities?category=stream'

# Read connectors (works for any role with view_stream). Read abilities are GET.
curl -u editor:password \
  'http://stream.wpenv.net/wp-json/wp-abilities/v1/abilities/stream/get-connectors/run'

# Create an alert (requires settings cap). Write abilities are POST.
curl -u admin:password -X POST \
  'http://stream.wpenv.net/wp-json/wp-abilities/v1/abilities/stream/create-alert/run' \
  -H 'Content-Type: application/json' \
  -d '{"input":{"alert_type":"highlight","trigger_author":"any","trigger_context":"any","trigger_action":"any"}}'

# Filtered purge. Destructive + idempotent abilities are DELETE.
curl -u admin:password -X DELETE \
  'http://stream.wpenv.net/wp-json/wp-abilities/v1/abilities/stream/purge-records/run' \
  -H 'Content-Type: application/json' \
  -d '{"input":{"confirm":true,"older_than_days":90}}'

# Unauthenticated request — expect 401/403.
curl 'http://stream.wpenv.net/wp-json/wp-abilities/v1/abilities/stream/get-records/run'

Checklist

  • Project documentation has been updated to reflect the changes in this pull request, if applicable.
  • I have tested the changes in the local development environment (see contributing.md).
  • I have added phpunit tests.

Release Changelog

  • New: Register 11 Stream operations as WordPress 6.9 Abilities API abilities under the stream/ namespace, gated behind a new "Enable Abilities API" advanced setting (default off). Read abilities respect Stream's view_stream capability; write abilities require the Stream settings capability.

Add abstract Ability base class, Abilities loader with WP 6.9 + setting
gating, and a new "Enable Abilities API" toggle under the existing
Advanced settings section. The loader hooks wp_abilities_api_init and
will register concrete abilities once they are added in subsequent
commits. Falls back silently on WordPress < 6.9.
Implements the six read-only abilities under stream/* namespace:
get-records, get-record, get-settings, get-alerts, get-connectors,
and get-exclusion-rules. Each ability has hand-written JSON Schemas,
delegates to existing Stream APIs, and ships with a PHPUnit test
covering name, schema, permission gating, and execution.

Tests skip themselves on WordPress < 6.9 via the shared
Abilities_TestCase base class.
Add three abilities that mutate Stream state through the existing internal
APIs: stream/create-alert (creates a wp_stream_alerts CPT post with
alert_type and alert_meta), stream/update-settings (partial-merge update
to the wp_stream option), and stream/create-exclusion-rule (appends to
the parallel-array exclude_rules option columns Stream already uses).

Each ability is gated behind the manage_options capability. Hand-written
JSON schemas describe inputs and outputs for AI consumers; create-alert
requires the four trigger fields, create-exclusion-rule requires at
least one filter property, and update-settings requires a non-empty
settings map. Each ability ships with PHPUnit coverage that verifies
permissions, schema shape, and end-to-end execution against the option
or post store.
Add the two destructive abilities required by the ticket:
stream/purge-records (filtered DELETE against the Stream records table
with a cascading meta delete that mirrors Admin::erase()) and
stream/delete-alert (force-delete a wp_stream_alerts post by ID).

purge-records refuses to run unless confirm: true is supplied AND at
least one filter (older_than_days, connector, context, action) is set,
preventing an accidental full table wipe. The row count is computed
before the DELETE so the response is meaningful even though the
multi-table DELETE returns the combined affected rows. delete-alert
returns a 404 WP_Error when the ID is unknown or refers to a non-alert
post type, which makes the ability safely idempotent.

Both abilities ship with PHPUnit coverage that exercises permissions,
schema validation, the happy path, the refusal paths, and (for
purge-records) the meta cascade.
Cover the two infrastructure pieces left untested by the per-ability
suites: tests/phpunit/test-class-ability.php exercises the Ability
abstract base via an in-file Fake_Ability_For_Test subclass (verifies
get_meta() emits category and show_in_rest, conditionally adds an
annotations key, and that the default permission_callback denies
subscribers and grants admins; also asserts register() makes the
ability retrievable via wp_get_ability() when the API is available).

tests/phpunit/test-class-abilities.php covers the loader: is_available()
tracks the WP_Ability class presence, is_enabled() reflects the
advanced_enable_abilities_api option, the constructor only hooks
wp_abilities_api_init when both gates pass, get_ability_slugs() lists
all eleven slugs, load_abilities() instantiates each, and
register_abilities() does not double-load on a second invocation.

Resolves: XWPENG-13
@PatelUtkarsh PatelUtkarsh marked this pull request as draft May 4, 2026 09:45
@PatelUtkarsh PatelUtkarsh changed the title Register Stream operations as WordPress Abilities (XWPENG-13) Register Stream operations as WordPress Abilities May 4, 2026
- Register 'stream' category on wp_abilities_api_categories_init so
  abilities with category=stream pass core's category-existence check
- Move 'category' from meta to top-level args in Ability::register(),
  matching the wp_register_ability() contract in WP 6.9
- Replace get-record's broken DB::get_records(['record' => $id]) call
  (Query class never implemented the singular 'record' arg) with a
  direct $wpdb single-row lookup
- Snapshot/restore $plugin->settings->options in Abilities_TestCase so
  in-memory mutations from write-ability tests don't leak across tests
- Update tests to satisfy the doing_action() guards on
  wp_register_ability() and wp_register_ability_category()
Add a per-ability 'instructions' annotation: a 1-2 sentence note for
AI agents about when and how to call each ability, distinct from the
description (which describes what it does).

Add tests/phpunit/abilities/test-rest-integration.php covering all
three ability types end-to-end: dispatches actual WP_REST_Requests
through WP_REST_Server and asserts 200/403/404/405 paths plus the
list-abilities endpoint exposes all 11 stream/* abilities. Catches
breakage in the real REST stack that direct execute() tests miss.

Add idempotent: true to purge-records annotations. WP core's REST
router only routes to DELETE when destructive AND idempotent are
both true; without idempotent the controller expects POST.

Refactor test action-firing to use the documented core test pattern
of pushing onto $wp_current_filter rather than registering callbacks
through add_action(). Cleaner, no global hook pollution, matches the
convention used in WordPress core's own abilities-api tests.

Make Abilities::register_abilities() defensive: skip per-ability
register() calls when the ability is already registered, preventing
spurious _doing_it_wrong notices when load_abilities() runs more
than once in the same process.
WP core's WP_Ability::invoke_callback() spreads zero arguments into the
execute callback when the ability declares no input_schema (see
wp-includes/abilities-api/class-wp-ability.php:506-512). Our previous
'execute($input)' signature required one argument, so any GET request
to a no-input-schema ability raised a fatal ArgumentCountError and
returned HTTP 500 to the caller.

Add '$input = null' as the default on the abstract Ability::execute()
plus all 11 concrete subclasses and the test fake. Null matches WP
core's own conventions (their invoke_callback and check_permissions
both default $input to null). Abilities that DO declare an
input_schema continue to receive the parsed value verbatim from core,
so the default sits unused for those.

Caught by live e2e testing against WP 6.9.1 (Phase 4 of
XWPENG-13-e2e.md): get-settings, get-connectors, and
get-exclusion-rules previously fataled.
- Authorization: read abilities use 'view_stream'; base default uses
  WP_STREAM_SETTINGS_CAPABILITY. Abilities registers a user_has_cap
  filter for REST contexts where Admin (and its filter) isn't loaded,
  so allowed roles can call read abilities consistently with the UI.
- update-settings: allowlist {section}_{field} keys from registered
  fields, run incoming values through Settings::sanitize_settings(),
  reject payloads with no recognized keys.
- create-exclusion-rule: schema gains format:ip and maxLength bounds;
  execute() sanitizes via sanitize_text_field(), validates IPs with
  FILTER_VALIDATE_IP, validates connector against registered slugs,
  rejects all-empty payloads.
- purge-records: use rows_affected from the DELETE itself (no stale
  pre-count); run orphan-meta sweep after; fix MySQL alias syntax.
- get-record: kept direct query (Query::query has a real array_shift
  bug with record__in) but adds explicit blog_id scoping on multisite
  so cross-site record leakage cannot occur.
The 5 read-only abilities (get-records, get-record, get-alerts,
get-connectors, get-exclusion-rules) all carried an identical
permission_callback() returning current_user_can( 'view_stream' )
with the same rationale docblock. Move it into a shared trait so the
authorization rule lives in one place.

Each ability file require_once's the trait directly so per-test loaders
(which require ability files individually) keep working without any
autoloader changes.

Net -35 LOC. Single-site and multisite Ability suites unchanged: 316
tests pass with the same skipped/incomplete counts as before.
- orderby: add enum bound to Query::query()'s actual sortable columns,
  and change the default from 'date' (not a real Stream column;
  silently fell back to ID) to 'created'. This makes the silent
  fallback impossible at the schema layer for REST callers and
  surfaces the contract for direct PHP callers.
- user_id__in / connector__in: add maxItems: 100 so a caller cannot
  force an unbounded IN(...) clause from a single request.

Tests cover schema shape, REST schema validation (orderby=date now
rejected, 101 items rejected), and a behavioral regression that seeds
two records with out-of-order created/ID and asserts orderby=created
ASC actually orders by created -- not by ID, which is what the old
silent fallback was doing.
…or-context

Mirror the admin form's create-alert flow (classes/class-alerts.php:766-
806) so API-created alerts behave identically to UI-created ones:

- Validate alert_type against $plugin->alerts->alert_types (the
  registered notifier slugs). Schema can't enum these because
  wp_stream_alert_types is a filter -- a hardcoded enum would lock out
  3rd-party notifiers. Reject unknown slugs with
  stream_unknown_alert_type / status 400 BEFORE inserting the post.
- Split 'connector-context' input into trigger_connector +
  trigger_context meta keys, exactly like the admin form does. Without
  the split, Alert_Trigger_Context::check_record() silently let any
  connector through because trigger_connector was never populated --
  alerts created via the API were effectively connector-agnostic.
- Build an Alert model from the split meta and use $alert->get_title()
  for post_title, so the admin list shows a meaningful title instead of
  'Auto Draft'.

Tests cover the title regression, the connector-dash-context split, and
the alert_type rejection path (including no-side-effects: no post is
inserted when validation fails).
…ed multisite

On a network-activated multisite install, the Abilities API toggle is saved
to the wp_stream_network site option via Network::update_site_option().
However, Settings::get_options() only reads from get_site_option() when
is_network_admin() is true; in REST and frontend contexts $plugin->settings->options
reflects the (typically empty) per-site option. As a result, is_enabled()
returned false in REST even when the network admin had enabled the API,
making the entire Abilities API silently unreachable on network-activated
sites.

Read the network option directly via get_site_option($settings->network_options_key)
when is_multisite() && $plugin->is_network_activated(), and fall back to
the existing in-memory per-site options otherwise (preserves single-site
and per-site-activated behavior).

Adds two regression tests:
- test_is_enabled_reads_network_option_when_network_activated (@group ms-required)
  flips the wp_stream_is_network_activated filter and proves is_enabled()
  follows the network option even when in-memory options say disabled.
- test_is_enabled_reads_per_site_options_when_not_network_activated proves
  the network option is ignored when the plugin isn't network-activated.
Add //end try comments to satisfy Squiz.Commenting.LongConditionClosingComment
on the new is_enabled() multisite tests, and lift the inline 'not a
registered notifier' comment above the array literal so it doesn't trip
Squiz.Commenting.PostStatementComment in the unknown-alert_type test.
The comment claimed format:ip is a hint not enforced by
rest_validate_value_from_schema(), which is wrong. WP core's
rest_is_ip_address() in wp-includes/rest-api.php DOES validate the
format and rejects bogus IPs at the schema layer with
ability_invalid_input before our execute() runs.

Reframe the in-method check as defense-in-depth for direct PHP callers
who invoke $ability->execute() outside the REST stack.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Integrates Stream with the WordPress 6.9 Abilities API by introducing an ability base class + loader, registering 11 Stream operations under the stream/ namespace (gated behind an Advanced setting), and adding a comprehensive PHPUnit suite to validate schemas, permissions, and REST routing.

Changes:

  • Added Ability (abstract base) and Abilities (loader/gating + category registration) and wired the loader into Plugin::init().
  • Implemented multiple stream/* abilities (read/write/destructive) backed by existing Stream internals, plus shared view_stream permission handling for read-only abilities.
  • Added PHPUnit infrastructure and per-ability tests, updated coverage configuration, and extended PHPCS configuration for Stream’s custom capability.

Review Findings (issues to address)

1) Critical issues

  • stream/purge-records can delete other sites’ logs on multisite when Stream is not network-activated
    • Why it matters: Stream’s tables are shared (base prefix) and rely on blog_id for separation; without a blog_id constraint this endpoint can purge records across the network.
    • Suggested fix: Add a stream.blog_id = %d constraint when is_multisite() and $this->plugin->is_multisite_not_network_activated() are true (mirroring Admin::erase_stream_records() / scheduled purge behavior).
      if ( $this->plugin->is_multisite_not_network_activated() ) {
      	$where[] = 'stream.blog_id = %d'; $params[] = get_current_blog_id();
      }
    • WP VIP ref: Multisite scoping via get_current_blog_id(); safe SQL construction via $wpdb->prepare().

2) High issues

  • stream/get-alerts can return alert_meta in a shape that violates its declared output schema
    • Why it matters: When alert_meta is missing, get_post_meta(..., true) returns ''; casting to (array) yields a numerically-indexed array which JSON-encodes as a list, not an object, breaking schema expectations and clients.
    • Suggested fix: Normalize alert_meta to an associative array/object: only return the meta value if it’s already an array, otherwise return an empty array.
    • WP VIP ref: Data consistency for REST outputs; get_post_meta() return-type handling.

3) Medium/Low issues

  • test_orderby_created_actually_orders_by_created_not_id() doesn’t reliably detect an orderby fallback-to-ID regression

    • Why it matters: The test data currently makes created order align with insertion/ID order, so it can pass even if orderby=created is ignored.
    • Suggested fix: Insert records such that ID ordering conflicts with created ordering (e.g., insert the “newer timestamp” record first and the “older timestamp” record second) and assert against that conflict.
    • WP VIP ref: PHPUnit test correctness (ensuring assertions can fail for the intended regression).
  • Abilities API toggle description lists an endpoint shape that conflicts with this PR’s REST integration tests

    • Why it matters: The admin-facing setting description references /wp-abilities/v1/stream/*, while tests exercise /wp-abilities/v1/abilities/stream/{slug}/run; this can mislead admins during manual verification.
    • Suggested fix: Update the UI description text to match the actual route structure used by the Abilities API implementation being targeted.
    • WP VIP ref: Admin UI clarity / accurate operational documentation.

Reviewed changes

Copilot reviewed 36 out of 36 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/phpunit/test-class-ability.php Unit tests for the abstract Ability base behavior (meta, permissions, registration).
tests/phpunit/test-class-abilities.php Unit tests for loader gating, multisite enablement behavior, and slug list.
tests/phpunit/fake-ability.php Concrete fake ability used by base-class tests.
tests/phpunit/abilities/test-rest-integration.php REST-level integration tests for routing/method enforcement and discovery.
tests/phpunit/abilities/test-class-ability-update-settings.php Tests for partial settings updates, key allowlisting, and memory refresh.
tests/phpunit/abilities/test-class-ability-purge-records.php Tests for destructive purge safety (confirm + filters) and cascade cleanup.
tests/phpunit/abilities/test-class-ability-get-settings.php Tests for settings read ability output and permissions.
tests/phpunit/abilities/test-class-ability-get-records.php Tests for records querying, schema allowlists/bounds, and ordering behavior.
tests/phpunit/abilities/test-class-ability-get-record.php Tests for single-record fetch behavior and not-found handling.
tests/phpunit/abilities/test-class-ability-get-exclusion-rules.php Tests for exclusion-rule pivoting and internal-key stripping.
tests/phpunit/abilities/test-class-ability-get-connectors.php Tests for connector discovery output shape.
tests/phpunit/abilities/test-class-ability-get-alerts.php Tests for alert listing and status filtering.
tests/phpunit/abilities/test-class-ability-delete-alert.php Tests for deletion safety, idempotency, and post-type validation.
tests/phpunit/abilities/test-class-ability-create-exclusion-rule.php Tests for rule append semantics, sanitization, and validation.
tests/phpunit/abilities/test-class-ability-create-alert.php Tests for alert creation, title generation, and alert-type validation.
tests/phpunit/abilities/abilities-testcase.php Shared Abilities test base (WP 6.9 gating, schema assertions, helpers).
tests/bootstrap.php Loads the new Abilities test base.
phpunit.xml Adds abilities/ directory to coverage include paths.
phpunit-multisite.xml Adds abilities/ directory to multisite coverage include paths.
phpcs.xml.dist Configures PHPCS to recognize view_stream as a custom capability.
classes/class-settings.php Adds the “Enable Abilities API” Advanced setting (WP 6.9+ only).
classes/class-plugin.php Wires Abilities loader into plugin initialization.
classes/class-ability.php Introduces the Ability abstract base and registration helper.
classes/class-abilities.php Introduces the Abilities loader (gating, category registration, instantiation).
abilities/trait-view-stream-permission.php Shared view_stream permission callback for read-only abilities.
abilities/class-ability-update-settings.php Implements stream/update-settings ability.
abilities/class-ability-purge-records.php Implements stream/purge-records ability (destructive).
abilities/class-ability-get-settings.php Implements stream/get-settings ability.
abilities/class-ability-get-records.php Implements stream/get-records ability with schema allowlists and paging.
abilities/class-ability-get-record.php Implements stream/get-record ability with multisite scoping guard.
abilities/class-ability-get-exclusion-rules.php Implements stream/get-exclusion-rules ability with pivoted output.
abilities/class-ability-get-connectors.php Implements stream/get-connectors ability (connector/context/action discovery).
abilities/class-ability-get-alerts.php Implements stream/get-alerts ability (status-filtered listing).
abilities/class-ability-delete-alert.php Implements stream/delete-alert ability (destructive + idempotent).
abilities/class-ability-create-exclusion-rule.php Implements stream/create-exclusion-rule ability with sanitization/validation.
abilities/class-ability-create-alert.php Implements stream/create-alert ability with alert-type validation and title generation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread abilities/class-ability-purge-records.php Outdated
Comment thread abilities/class-ability-get-alerts.php Outdated
Comment thread tests/phpunit/abilities/test-class-ability-get-records.php Outdated
Comment thread classes/class-settings.php Outdated
…rby test, UI text

- purge-records: scope DELETE by blog_id on non-network-activated multisite
  to prevent cross-site record deletion; mirrors Admin::erase_stream_records().
- get-alerts: coerce missing alert_meta to {} instead of [""] so the response
  matches the declared object output schema.
- test_orderby_created_actually_orders_by_created_not_id: invert insertion
  order so ID-order conflicts with created-order; the test would now fail if
  the implementation silently fell back to ORDER BY ID.
- Settings UI: drop the specific /wp-abilities/v1/stream/* path from the
  toggle description (the actual route is owned by core's Abilities API).
- Add regression tests for the missing alert_meta normalization and for the
  per-blog purge isolation (multisite-only).
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated 5 comments.

Comment thread abilities/class-ability-get-record.php Outdated
Comment thread abilities/class-ability-purge-records.php Outdated
Comment thread abilities/class-ability-purge-records.php Outdated
Comment thread classes/class-settings.php
Comment thread tests/phpunit/test-class-abilities.php
…e, UTC cutoff, settings UX, hook isolation

- get-record / purge-records: scope by blog_id = get_current_blog_id() on
  any multisite request that is not is_network_admin() (REST is never
  network-admin). Replaces the previous is_multisite_not_network_activated()
  predicate, which left network-activated installs unprotected against a
  per-site admin reading or purging another site's records via REST.
  Mirrors Network::network_query_args() default scoping.
- purge-records: compute the older_than_days cutoff as a UTC DateTime in
  PHP and bind as %s, mirroring Admin::purge_scheduled_action(). The
  previous DATE_SUB(NOW(), INTERVAL %d DAY) used the MySQL server timezone
  while Stream's created column is UTC, so it could over- or under-purge
  on hosts where the server timezone is not UTC.
- Settings: hide the "Enable Abilities API" toggle on per-site settings
  screens when Stream is network-activated. The setting is read from the
  network option in that mode, so a per-site checkbox would have been a
  silent no-op.
- Tests: snapshot and restore $wp_filter['wp_abilities_api_init'] in
  Test_Abilities::setUp/tearDown so the existing remove_all_actions()
  calls inside individual tests don't pollute the global hook registry
  for subsequent tests in the same process.
- Tests: lock the new behaviors with regression coverage:
  * get-record returns stream_record_not_found for foreign-blog IDs on
    multisite (must not leak via guessing).
  * purge-records does not cross blog boundaries on multisite (rename
    drops the _when_not_network_activated suffix).
  * purge-records older_than_days uses a UTC cutoff: rows seeded with
    explicit UTC timestamps purge correctly regardless of server tz.
  * Settings field is visible on non-network-activated installs.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated 4 comments.

Comment on lines +174 to +198
// Delete stream rows first and capture rows_affected so the response reflects
// the actual count (rather than a stale pre-DELETE COUNT). $params is guaranteed
// non-empty here by the count( $where ) === 1 guard above. MySQL requires the
// "DELETE alias FROM tbl AS alias" form when the WHERE references an alias.
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.DirectDatabaseQuery
$delete_sql = "DELETE stream FROM {$wpdb->stream} AS stream WHERE {$where_sql}";
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query( $wpdb->prepare( $delete_sql, $params ) );
$deleted = (int) $wpdb->rows_affected;

if ( 0 === $deleted ) {
return array( 'deleted' => 0 );
}

// Sweep orphaned meta rows whose parent record was just deleted. Idempotent
// and safe to run unconditionally; the LEFT JOIN scopes the cleanup to
// orphans across the whole streammeta table, which also catches any prior
// orphans without growing this query's blast radius beyond a single sweep.
// phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared
$wpdb->query(
"DELETE meta FROM {$wpdb->streammeta} AS meta
LEFT JOIN {$wpdb->stream} AS stream ON stream.ID = meta.record_id
WHERE stream.ID IS NULL"
);

'properties' => array(
'deleted' => array(
'type' => 'integer',
'description' => 'Number of stream records deleted (meta rows are cascaded by record_id).',
Comment on lines +175 to +177
// the actual count (rather than a stale pre-DELETE COUNT). $params is guaranteed
// non-empty here by the count( $where ) === 1 guard above. MySQL requires the
// "DELETE alias FROM tbl AS alias" form when the WHERE references an alias.
Comment on lines +5 to +8
* Verifies that abilities registered through Abilities API actually serve HTTP
* requests at /wp-abilities/v1/abilities/stream/{slug}/run with correct status
* codes and method routing. Complements the per-ability unit tests, which only
* exercise execute() in isolation.
…atement

Replace the post-DELETE orphan sweep over the entire streammeta table with
a single multi-table DELETE that removes matching stream rows and their
meta in one statement, mirroring Admin::purge_scheduled_action(). Capture
the parent count up-front so the response still reports records-deleted
independent of how many meta rows were attached.

Also drop a stale comment that referenced a guard pattern no longer in
the code, and update the output-schema description so it matches the
implementation (no more 'cascade' wording, since there is no FK).
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated 4 comments.

Comment on lines +118 to +121
// Refresh in-memory copy so subsequent abilities see the change.
$this->plugin->settings->options = $merged;

return $merged;
'properties' => array(
'settings' => array(
'type' => 'object',
'description' => 'Partial settings map keyed by {section}_{field} (e.g. general_records_ttl). Unknown keys are rejected; values are normalized through Stream\'s settings sanitizer. Omitted keys are preserved.',
Comment on lines +215 to +224
$options['exclude_rules'] = $rules;
update_option( $option_key, $options );

// Refresh in-memory copy.
$this->plugin->settings->options = $options;

return array(
'index' => $index,
'rule' => $rule,
);
return array(
'type' => 'object',
'additionalProperties' => true,
'description' => 'Settings keyed by {section}_{field} (e.g. general_records_ttl, advanced_enable_abilities_api).',
…hten schema descriptions

After update_option() the raw option array is sparse (omits defaults). Both
update-settings and create-exclusion-rule were assigning that sparse array
directly to $plugin->settings->options, leaving default-only keys missing
for any later code in the same request. Refresh the in-memory copy via
Settings::get_options() so defaults are merged in.

Also align two schema descriptions with actual behavior:

- update-settings now says unknown keys are *ignored* (the request only
  fails when no key matches a registered setting). This matches the
  array_intersect_key() filtering in execute().
- get-settings no longer promises advanced_enable_abilities_api in every
  response; that field is only registered on WP 6.9+ and, on
  network-activated multisite, only from network admin.
When a wp_stream_alerts post has no alert_meta postmeta row, the ability
was emitting an empty PHP array(), which JSON-encodes as [] and violates
the declared 'type: object' output schema.

Replace the empty array with a stdClass instance so wp_json_encode()
emits {} as the output schema requires. Also strengthen the regression
test to assert against the JSON-encoded payload (the previous PHP-level
array() comparison was passing despite the wire-format bug).
@PatelUtkarsh PatelUtkarsh requested a review from Copilot May 7, 2026 08:15
Squiz.Commenting.FunctionComment.WrongStyle was flagging the three
test methods that had only inline section comments above them, and
Generic.Commenting.DocComment.MissingShort was flagging the
@expectedIncorrectUsage-only docblock on test_unknown_ability_returns_404.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 36 out of 36 changed files in this pull request and generated 6 comments.

Comment thread abilities/class-ability-get-records.php
Comment thread abilities/class-ability-update-settings.php
Comment thread abilities/class-ability-purge-records.php
Comment thread abilities/class-ability-purge-records.php Outdated
Comment thread abilities/class-ability-delete-alert.php Outdated
Comment thread classes/class-abilities.php
…empotent category, doc fix

Four issues from the latest Copilot review on this branch:

- update-settings: PHP booleans on checkbox keys were silently coerced to ''
  by Settings::sanitize_setting_by_field_type() (it gates on is_numeric()).
  JSON clients naturally send true/false for checkbox-typed settings, so
  the round-trip would store '' instead of 0/1. Walk the registered fields
  to identify checkbox keys and normalize bools to 0/1 before sanitization.
  Add a regression test that round-trips both true and false.
- purge-records: the multi-table DELETE result was discarded, so a
  database-side failure (lock-wait timeout, deadlock, etc.) would still
  return the pre-counted 'deleted' as if the purge had succeeded. Check
  $wpdb->query()'s return value and surface a 500 WP_Error on false.
- class-abilities: register_category() now bails when the category is
  already registered, mirroring the idempotency pattern in
  register_abilities() and avoiding a core _doing_it_wrong notice when
  multiple loader instances exist (which the test harness already
  works around).
- get-records: the orderby description claimed unknown values fall back
  to ID in Query::query(), but the schema enum rejects them at REST
  validation. Tighten the description to match.
@PatelUtkarsh PatelUtkarsh marked this pull request as ready for review May 7, 2026 09:19
Copy link
Copy Markdown
Member

@bartoszgadomski bartoszgadomski left a comment

Choose a reason for hiding this comment

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

@PatelUtkarsh Thank you for working on this exciting new feature!

Please see my feedback below - it's mostly related to separation of concerns and de-duplicating logic.

Comment thread abilities/class-ability-create-alert.php Outdated
Comment thread abilities/class-ability-create-alert.php Outdated
Comment thread abilities/class-ability-delete-alert.php Outdated
Comment thread abilities/class-ability-get-alerts.php Outdated
Comment thread abilities/class-ability-get-alerts.php Outdated
Comment thread abilities/class-ability-get-connectors.php Outdated
Comment thread abilities/class-ability-get-record.php Outdated
Comment thread classes/class-abilities.php
Keep abilities thin and delegate to the existing data-flow
classes so the admin UI and the Abilities API stay on one code
path.

- Alerts: add STATUS_ENABLED / STATUS_DISABLED constants and a
  get_alerts() listing method; consume both from get-alerts ability.
- Alert: add delete() method; consume from delete-alert ability.
- create-alert ability now delegates the insert + meta save to
  Alert::save() instead of duplicating wp_insert_post / update_post_meta.
- Connectors: add get_all() and get_slugs() helpers; consume from
  get-connectors and create-exclusion-rule abilities.
- Record: add static get_by_id($id, $blog_id) for single-row + meta
  fetch; consume from get-record ability (multisite scoping kept in the
  ability layer, where the REST/network-admin distinction belongs).
- Settings: add get_setting_value() that transparently reads the network
  option on network-activated multisite; Abilities loader uses it
  instead of its own multisite branching.
@PatelUtkarsh
Copy link
Copy Markdown
Member Author

Thanks for the thorough review! @bartoszgadomski Addressed all the feedback in 8e4637a.

The main theme was routing abilities through existing classes (Alert::save(), Alert::delete(), Alerts::get_alerts(), Connectors::get_all(), Record::get_by_id(), Settings::get_setting_value()) instead of duplicating logic in the ability layer.

Copy link
Copy Markdown
Member

@bartoszgadomski bartoszgadomski left a comment

Choose a reason for hiding this comment

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

@PatelUtkarsh Thank you for implementing the requested changes!

I have a few more findings - note these are AI generated, so please double-check whether there's no any false alarms. Beside the inline comments shared below for critical issues, here are other findings:

Medium

advanced_enable_abilities_api is unreachable through stream/update-settings on network-activated multisite

Settings::get_fields() only appends the toggle when ! is_network_activated() || is_network_admin(). REST is never is_network_admin(), so on a network-activated install the field is missing from get_fields() everywhere except the network-admin settings screen. update-settings builds its allowlist from get_fields(), so the key gets array_intersect_key-stripped and the call fails with stream_no_valid_settings. This is arguably intentional (force the toggle through the network admin UI), but it should be either documented or — better — whitelisted explicitly so an MCP client can still flip it. Same gap applies to get-settings, where the toggle's current value will never appear in the response on network-activated installs.

stream/get-connectors returns a frontend-only subset, breaking stream/create-exclusion-rule validation

Connectors::load_connectors() honors register_frontend = false and skips those connectors when ! is_admin() — REST satisfies ! is_admin(). The following connectors set register_frontend = false: blogs, taxonomies, settings, editor, menus, installer, jetpack, mercator. Two visible consequences:

  1. get-connectors returns a list that's a strict subset of the connectors an admin sees in the UI, which is misleading for AI clients trying to enumerate what's tracked on the site.
  2. create-exclusion-rule validates connector against $plugin->connectors->get_slugs(). Trying to exclude connector: "settings" or connector: "editor" from the REST API will hit stream_unknown_connector, even though those connectors are real, log records, and are valid in the UI.

The cleanest fix is to either force register_frontend = true for connectors that should be discoverable via abilities, or to drive the validation list from a non-context-dependent source.

update-settings re-triggers the TTL purge hook

Settings::__construct() hooks update_option_$option_keyupdated_option_ttl_remove_records. update-settings writes through update_option, so any REST call that changes general_records_ttl (or even just touches the option) will fire that hook in the REST request. Likely desired, but worth confirming — historically that hook scheduled an async cascade purge, and it now runs in user-facing REST latency. Either reuse the existing path explicitly or document.

Lower priority / nits

  • Ability_Get_Alerts::execute() casts (string) $alert->status and returns it. The schema enums to [STATUS_ENABLED, STATUS_DISABLED], but Alert::__construct actually keeps whatever the post status is (e.g. trash). Since the listing only fetches wp_stream_enabled/wp_stream_disabled from Alerts::get_alerts(), this is fine today — but if a third-party plugin trashes an alert and you later expand get_alerts() to include other statuses, the output will violate the enum. Cheap defensive coercion or an if ( ! in_array( $status, ..., true ) ) continue; would future-proof this.

  • Ability_Create_Alert output schema vs. reality: the response builds alert_type via get_post_meta(...) which returns '' (not null) when meta is missing. Schema is type: [string, null], so an empty string is fine — but alert_meta is cast (array) get_post_meta(...) and if Alert::save() ever leaves meta unsaved (e.g. a failed update_post_meta), (array) '' becomes [ 0 => '' ], a list. Same JSON-shape bug class as the get-record meta shape. Currently unreachable because save() always writes a non-empty alert_meta, but worth a defensive normalize before returning.

  • Trait_View_Stream_Permission lives in abilities/trait-view-stream-permission.php and is require_once'd at the top of every ability file that uses it. With the loader's include_once and PHPUnit's per-test require_once, this works, but it's also fragile: rename the file or change the autoload prefix and the abilities silently lose their permission callback (and fall back to the WP_STREAM_SETTINGS_CAPABILITY default — which would over-restrict reads, not under-restrict). Consider moving the trait to classes/ so the existing PSR-4-ish autoloader picks it up via Trait_View_Stream_Permissionclass-trait-view-stream-permission.php, or rename to live under the existing autoload conventions.

  • Abilities::register_category / register_abilities / filter_user_caps callbacks are registered as array( $this, '...' ). The workspace coding rule (Avoid Anonymous Functions in PHP) discourages instance-method callbacks in favor of static or named functions, but the rest of Stream consistently uses array( $this, '...' ) (e.g. Settings::__construct(), Admin::__construct()). New code is therefore consistent with the repo but inconsistent with the stated standard. Worth a project-level decision before this lands; not a blocker.

  • Test_Ability_Update_Settings::test_partial_update_preserves_other_keys and friends pass only because they exercise the single-site code path. Once the multisite write issue is addressed, you'll want a @group ms-required test that proves a write lands in wp_stream_network on network-activated installs (mirroring test_is_enabled_reads_network_option_when_network_activated).

  • Test_Abilities::test_load_abilities_instantiates_each_slug and test_load_abilities_populates_all_slugs are functionally duplicates — both assert count 11 + iterate the slugs. Worth collapsing.

  • Ability_Get_Settings doesn't override the base permission_callback, so it uses WP_STREAM_SETTINGS_CAPABILITY. The PR description says read abilities use view_stream and only write/destructive abilities use the settings cap; the doc is slightly off — get-settings is read but gated on the settings cap. Reasonable choice (settings can include role allowlists), but worth documenting why this one read ability is treated differently.

  • Ability::register() includes a final qualifier, which is great for the contract, but it means a subclass that ever needed to inject e.g. a precondition (register_only_if_woocommerce_loaded) has nowhere to hook. Add a pre_register() hook method or remove final later if needed; minor.

$args['records_per_page'] = self::DEFAULT_PER_PAGE;
}

$records = $this->plugin->db->get_records( $args );
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.

@PatelUtkarsh get-record and purge-records were given an explicit is_multisite() && ! is_network_admin() blog guard, but get-records was missed. The reasoning that protects the others applies here too, and the wp_stream_query_args filter that normally injects blog_id is never registered. A user with view_stream on any blog can call stream/get-records and receive records from every other blog.

Apply the same scoping as get-record / purge-records:

Suggested change
$records = $this->plugin->db->get_records( $args );
if ( is_multisite() && ! is_network_admin() ) {
$args['blog_id'] = get_current_blog_id();
}
$records = $this->plugin->db->get_records( $args );

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch! Fixed in 71d6443. Added the same is_multisite() && ! is_network_admin() guard get-record and purge-records have.

$sanitized = $this->plugin->settings->sanitize_settings( $filtered );
$merged = array_merge( $current, $sanitized );

update_option( $option_key, $merged );
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.

@PatelUtkarsh is_enabled() method was fixed to read via Settings::get_setting_value() so it consults wp_stream_network on network-activated installs. The writes weren't; In REST on a network-activated install, Settings::get_option_key() returns wp_stream (the per-site key), but the authoritative store is wp_stream_network (written by Network::network_admin_save_options() via update_site_option).

Result: an admin who flips a setting via the API thinks it was saved, but Abilities::is_enabled(), Admin::purge_scheduled_action(), and the in-network-admin UI all still read the old value. This should route through a setter on Settings that knows about the network case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch! Fixed in 71d6443. Added Settings::update_all_setting_values() and get_all_setting_values() that route through update_site_option/get_site_option on network-activated multisite.

}

$options['exclude_rules'] = $rules;
update_option( $option_key, $options );
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.

Same concern applies here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 71d6443 alongside update-settings.

Comment thread classes/class-abilities.php Outdated
return $allcaps;
}

$role_access = isset( $this->plugin->settings->options['general_role_access'] )
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.

Same multisite gap on the read side: the role-access dynamic cap filter reads $plugin->settings->options['general_role_access']. On network-activated multisite REST contexts, $plugin->settings->options is the (typically empty) per-site option. So users who would be granted view_stream via the network admin's Role Access setting won't get it through the abilities endpoint - read abilities silently return 403 for any non-administrator. Use Settings::get_setting_value( 'general_role_access', array() ) here, mirroring what was done for the toggle.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good catch! Fixed in 71d6443. filter_user_caps() now reads via Settings::get_setting_value().

Comment thread classes/class-record.php
return null;
}

$row['meta'] = (array) get_metadata( 'record', $row['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.

When the record has no meta, get_metadata() returns [], which JSON-encodes as [], violating the declared meta: { type: object } in the output schema. The same 5117ef1 commit that fixed alert_meta should be applied here - coerce empty to new \stdClass() (or is_array() && ! empty() ternary).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in 71d6443. Coerced empty meta to new \stdClass() in Ability_Get_Record::execute(). Keeps JSON-shape concerns in the ability layer and the model neutral.

…uting

- Add Settings::get_all_setting_values() and update_all_setting_values()
  that route through the network option on network-activated multisite.
  Previously, update-settings and create-exclusion-rule wrote to the
  per-site option in REST contexts, while is_enabled() and the admin UI
  read from the network option. Writes ghost-saved.
- Route Abilities::filter_user_caps() through get_setting_value() so
  general_role_access is honored on network-activated REST contexts.
  Previously, editors granted view_stream via the network admin's Role
  Access setting silently got 403 from read abilities.
- Scope get-records to the current blog when is_multisite() and not in
  network admin. Mirrors guards already in get-record and purge-records.
  The wp_stream_query_args filter that normally injects this is only
  registered inside Admin, which doesn't load in REST.
- Coerce empty record meta to stdClass in Ability_Get_Record so the
  response satisfies the declared meta: object output schema. Same fix
  class as the earlier alert_meta normalization.
- Stop hiding the Abilities API toggle from network admin UI on
  network-activated installs (keep it hidden only from the per-site
  settings screen, where saving would be a no-op). REST/CLI clients can
  now flip it via update-settings since writes are routed correctly.
Connectors::load_connectors() skips connectors whose register_frontend is
false when the request isn't wp-admin. REST is ! is_admin(), so connectors
like settings, editor, menus, taxonomies, blogs, installer, jetpack, and
mercator were absent from stream/get-connectors and unknown to
stream/create-exclusion-rule's validation enum -- even though those
connectors are real, configurable in the wp-admin UI, and log records on
the site.

Add Connectors::get_all_including_admin_only() and
get_all_slugs_including_admin_only(), which walk the full connector class
list and return metadata for every dependency-satisfied connector,
ignoring the is_admin() gate. They never call register() and do not
mutate the live $this->connectors registry, so hooks fire exactly as
before. Route the two affected abilities through the new methods.

Verified against the live xwp-demo deployment: stream/get-connectors was
returning 6 entries; with this fix it returns all 14+ that an admin sees
in the UI, and stream/create-exclusion-rule now accepts connector slugs
like "settings".
…ultisite

E2E surfaced a read/write mismatch: update-settings and create-exclusion-rule
correctly wrote to wp_stream_network on network-activated installs, but
get-settings and get-exclusion-rules still read from $plugin->settings->options
(per-site, populated via Settings::get_options() which gates on
is_network_admin() and falls back to get_option() in REST). After a write,
the read endpoint returned the old per-site value instead of the just-
persisted network value.

Route both read abilities through Settings::get_all_setting_values() and
make sure the in-memory cache that update_all_setting_values() refreshes
also reads from the network option (Settings::get_options() can't be
trusted on network-activated REST because is_network_admin() is always
false there). Also return from update-settings via get_all_setting_values()
so the response reflects the authoritative store.
- Filter Ability_Get_Alerts results to the declared status enum
  (STATUS_ENABLED / STATUS_DISABLED) so a third-party-trashed alert or
  future broadening of Alerts::get_alerts() doesn't leak a non-enum
  status into the response and violate the output schema.
- Normalize empty alert_meta to stdClass in Ability_Create_Alert output,
  mirroring the coerce in Ability_Get_Alerts. Currently unreachable
  because Alert::save() always writes the merged trigger keys, but the
  coerce is cheap defense and keeps the JSON output consistent.
- Add Test_Ability_Update_Settings::test_write_targets_network_option_when_network_activated
  -- mirror of the read-side coverage in
  Test_Abilities::test_is_enabled_reads_network_option_when_network_activated,
  proves update-settings writes land in wp_stream_network on network-
  activated multisite and do not touch the per-site option.
- Merge duplicate Test_Abilities::test_load_abilities_instantiates_each_slug
  and test_load_abilities_populates_all_slugs into a single test that
  covers both population and instantiation in one pass.
Each of the 5 read abilities (get-records, get-record, get-alerts,
get-connectors, get-exclusion-rules) previously did its own
require_once for trait-view-stream-permission.php at the top of the
class file. A new read ability that forgot the require would silently
fall back to Ability::permission_callback() and gate on
WP_STREAM_SETTINGS_CAPABILITY instead of view_stream.

Move the require_once into the two chokepoints that actually load
ability files: Abilities::load_abilities() for production and
Abilities_TestCase::setUp() for PHPUnit. New abilities now get the
trait available automatically -- no per-file require to forget.

Verified via PHP introspection (trait_exists, class_uses on all 5
ability classes) and a live REST smoke test that admin GETs return
200 and subscriber GETs return 403 across all five read abilities.
@PatelUtkarsh
Copy link
Copy Markdown
Member Author

PatelUtkarsh commented May 15, 2026

Thanks for the second pass! All five inline points addressed in 71d6443. @bartoszgadomski

Medium

  • advanced_enable_abilities_api unreachable on network-activated REST: fixed in 71d6443.
  • get-connectors frontend-only subset / create-exclusion-rule rejects valid connectors: fixed in 0f8e5a0. Added Connectors::get_all_including_admin_only() that ignores the register_frontend gate, metadata-only, never calls register().
  • TTL purge hook on REST writes: same hook fires when admin UI saves. No code change.

Nits addressed in d1fc2e0:

  • Defensive status enum filter in Ability_Get_Alerts.
  • Defensive empty-alert_meta coerce in Ability_Create_Alert.
  • New Test_Ability_Update_Settings::test_write_targets_network_option_when_network_activated.
  • Merged duplicate Test_Abilities slug tests.
  • Documented Ability_Get_Settings capability choice.

Trait_View_Stream_Permission fragility: fixed in a65b9cf. Centralized the require_once in Abilities::load_abilities() and the shared test case. New read abilities pick up the trait automatically.

Deferring:

  • array( $this, '...' ) callbacks: consistent with rest of Stream; repo-wide decision.
  • final on Ability::register(): YAGNI.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants