Skip to content

Close the v2 re-audit's framework findings - #65

Merged
ahegyes merged 15 commits into
trunkfrom
fix/reaudit-followups
Oct 29, 2026
Merged

ahegyes merged 15 commits into
trunkfrom
fix/reaudit-followups

Conversation

@ahegyes

@ahegyes ahegyes commented Oct 29, 2026 •

Copy link
Copy Markdown
Owner

Summary. Fifteen commits from the v2 re-audit, each standing alone:

  • the migration runner looped inside Action Scheduler's runners and churned its lock while a batched step was pending;

  • attachment meta boxes discarded every save;

  • a network notice dismissed on one network hid the same notice on every network;

  • the updater offered tags that version_compare() misorders;

  • the package split could race and force-push from any branch;

  • the changelog directory vanished after a release;

  • three record claims had no test;

  • the scheduler backends carried an unused parameter;

  • three npm scripts downloaded @wordpress/scripts at run time;

  • several docs had drifted from the code;

  • the core persona taught an anatomy the conventions reject;

  • the workflows and locks pinned wordpress-configs several merges behind its trunk.

  • Migration runner:

    • inside its own hook, a spent budget schedules the continuation a second ahead instead of now, so wp action-scheduler run and the async runner end instead of claiming it again at once;
    • outside the hook, run() reads the step at the cached revision first, and enqueues a pending batched step without taking the lock, where it had cost nine queries a request;
    • limitation 2 says what such a request costs.
  • Attachments: wp_insert_post() fires edit_attachment instead of save_post for an attachment, so a PostMetaBox whose types include attachment also saves there. The form's nonce check still turns away updates that post no form.

  • Network notices: a viewer's state for network notices lives in installation-wide user meta, and it is now keyed network:{network_id}:{id}.

  • Updater: tag suffixes are limited to -dev, -alpha, -beta or -rc, in any case and optionally numbered, as Shared's Version accepts. A prerelease-channel site had been offered 2.1.0-pre.1 over 2.1.0.

  • CI:

    • Split Packages takes one concurrency group per ref without cancelling a run in progress, and runs only for trunk and v* tags;
    • changelog/.gitkeep keeps the directory after changelog:write empties it;
    • Quality runs changelog:validate.
  • Tests: new rows for record claims that had none:

    • serialized-looking strings round-trip as strings through every cache kind and through OptionRows;
    • without WP_DEBUG, the logger's default threshold is warning;
    • a kernel's second boot adds disjoint roots and throws, before registering anything, for a class already walked.
  • Scheduler backends: BackendInterface::enqueue() drops bool $unique, which its one caller always set and the WP-Cron backend ignored.

  • Tooling: @wordpress/scripts is a locked devDependency, and packages-update:wp, check:engines and check:licenses call its binary instead of npx --yes @wordpress/scripts@latest.

  • Docs:

    • SettingsTab, SettingsSection, Fields::is_drawn_by_forms() and limitation 4 say that a settings field's render callable needs a type WooCommerce does not draw;
    • limitation 3 adds a single site with many rows to what can outlast a web uninstall's time limit, and limitation 5 names the migration notices beside the boot notices;
    • OptionRows says it writes rows without autoload, and OrderMetaBox says where WooCommerce fires its save hook;
    • the layout tree names floors by key, and the audit command says what npm's half covers.
  • Core persona: its migration steps are prefixed functions in includes/migrations.php, and its root keeps the runner in a protected(set) property, as consumer-a and the template do. The core README excerpt follows.

  • Configs pins: every workflow ref and lock moves from 88710a7 to configs 3cbd985. The reusables the framework calls change only their action pins and comments, with no input changes, and each lock changes only its configs entry.

Production code +83 −32 (13 files), tests +161 −9 (11 files), fixtures +60 −63 (6 files), docs +15 −14 (3 files), workflows +8 −1 (2 files), changelog/.gitkeep, the configs pins in six workflows and eight locks, and the npm manifest and lock for @wordpress/scripts (+21,704 −3,074, nearly all lock).

Review points.

  • Full npm audit: the full-tree npm audit behind npm run audit now reports @wordpress/scripts' development advisories (13, one high), as the template's and configs' trees do. CI's npm audit --omit=dev passes, since the framework has no runtime npm packages.
  • Internal signature: BackendInterface sits in core's internal Scheduling\Backends\ namespace, so dropping its parameter changes no contract.
  • Unreleased changes: a network-notice dismissal stored under the old key is no longer read, and a tag with another suffix, such as -pre.1 or -patch, is no longer offered. Nothing is released, so @version stays 2.0.0 and no migration or changelog fragment ships.

Testing.

  • composer quality-check passes: validate, changelog validation, PHPCS on both profiles, the PHPStan profiles and every fixture's, Unit 192 and Integration 1345.
  • composer test:integration:multisite passes (41), and so does composer test:integration:php74 (2).
  • actionlint, zizmor, npm run check:engines, npm run check:licenses and a PHP 7.4 php -l of packages/bootstrap are clean.
  • Before the fixes: the runner, attachment, network-notice and updater rows failed on trunk. The rows that pin existing behavior fail under mutants: OptionRows skipping serialization for strings, and the persona discarding its runner.

Inside its own hook, a request whose budget was spent scheduled the
continuation due now. The queue run that dispatched the hook claimed it
at once, and since the runner's deadline belongs to the object, every
later invocation in that process started spent and scheduled another:
`wp action-scheduler run` never exited, and the async runner churned
empty actions until its time limit, while the step did not advance.

The continuation is due a second later, so the next claim of the queue
run in progress finds nothing and the run ends; a later request runs
the step with a fresh budget. A row claims as of the first dispatch and
finds the continuation pending, and the dispatch rows claim as later
requests would.

Assisted-by: Claude Code:claude-opus-5-5
Outside the runner's hook, a batched step only enqueues the hook, but
run() took the lock before it looked at the step, so while a batched
migration was pending every request inserted and deleted the lock row
and read it back, nine queries for a step it would never run.

run() reads the step at the cached revision first, and at a batched
step it enqueues the hook and returns without the lock; a stale
revision leaves only plain steps to the hook, which runs them too. The
scheduling moves to one helper shared with the spent-budget pause.
Limitation 2 says what such a request costs. A row records the option
writes of a request at a pending batched step and finds none to the
lock.

Assisted-by: Claude Code:claude-opus-5-5
PostMetaBox saved its form on save_post, but wp_insert_post() returns
before save_post for an attachment and fires edit_attachment instead.
A box on the attachment edit screen rendered and posted its fields,
WordPress redirected with "Media file updated", and every value was
discarded.

A box whose types include attachment also saves on edit_attachment.
The form's own nonce check still turns away the media modal's saves
and other updates that post no form. A row saves an attachment through
post.php and finds the value stored.

Assisted-by: Claude Code:claude-opus-5-5
A viewer's dismissals and showings of network notices live in their
{prefix}_network_hidden_notices user meta, keyed network:{id}. User meta
is shared by every network of an installation, while network notices
are stored per network, so dismissing a notice on one network hid the
notice of the same id on every other network.

The key names the current network, network:{network_id}:{id}, through
one helper that the read and the write share. A multisite row dismisses
a per-viewer network notice, switches the current network and finds the
other network's notice of that id still shown; the key rows follow.

Assisted-by: Claude Code:claude-opus-5-5
The updater accepted any tag suffix of letters, digits and dots, but
PHP's version_compare() ranks suffixes it does not know, such as -pre,
-patch or a bare number, above the release they precede. A site on
the prerelease channel was offered 2.1.0-pre.1 over 2.1.0, and a site
on 2.1.0-pre.1 was never offered 2.1.0.

The suffix is limited to -dev, -alpha, -beta or -rc in any letter case,
optionally numbered, the labels version_compare() orders below their
release, as Shared's Version accepts them; the bootstrap README names
them. Rows list 2.1.0 beside 2.1.0-pre.1 and offer 2.1.0, and offer an
uppercase -RC1 tag.

Assisted-by: Claude Code:claude-opus-5-5
Split Packages had no concurrency group, so two pushes to trunk in quick
succession split in parallel and the older run could force-push its
split over the newer one on every mirror's trunk. A dispatch from any
other branch also force-pushed that branch's split to the mirrors'
trunk, which consumers resolving ^2.0 install until a tag exists.

The workflow takes one concurrency group per ref without cancelling a
run in progress, and the job runs only for trunk or a v* tag.

Assisted-by: Claude Code:claude-opus-5-5
changelog:write deletes the fragments it writes into CHANGELOG.md, and
changelog/ held no other file, so after a release a fresh clone had no
changelog/ and changelog:validate, part of quality-check, failed on the
missing directory. No workflow ran changelog:validate either, so a
malformed fragment reached trunk unnoticed.

changelog/.gitkeep keeps the directory, and Quality's PHP lint runs
changelog:validate beside PHPCS and PHPStan.

Assisted-by: Claude Code:claude-opus-5-5
CacheEntries::wrap() and OptionRows::serialize() rely on
maybe_serialize() serializing a string that already looks serialized,
so it reads back as the string rather than as the value it spells out,
which would let stored text become an object. No row stored such a
string, so a change to either serializer could drop that guarantee
unnoticed.

The cache rows' values gain a serialized array and an object's text,
and an OptionRows row inserts and replaces both and reads them back as
strings. Skipping maybe_serialize() for strings fails the new row.

Assisted-by: Claude Code:claude-opus-5-5
Every environment the suites run in defines WP_DEBUG as true, so the
warning threshold AbstractLogger applies without it was never
exercised, and no row booted a kernel twice, though the record states
that a second boot adds disjoint roots and throws for a class already
walked.

A Unit row, where WP_DEBUG is undefined, finds the default threshold at
warning's severity, and two kernel rows boot a second, disjoint tree
and a second tree that reaches a walked class, which throws before it
registers anything.

Assisted-by: Claude Code:claude-opus-5-5
BackendInterface::enqueue() took a bool $unique, but its one caller,
Scheduler::enqueue(), always passed true after its own census found
nothing pending or running, and the WP-Cron backend ignored it. The
flag offered a non-unique enqueue no code could reach.

enqueue() takes the hook and its priority, and the Action Scheduler
backend passes unique to Action Scheduler itself, which still guards
the race between the census and the insert.

Assisted-by: Claude Code:claude-opus-5-5
Three npm scripts ran npx --yes @wordpress/scripts@latest, which
downloads whatever version is newest at each run, outside the lock and
any audit, and lets npx resolve the command at run time.

@wordpress/scripts is a locked devDependency, and packages-update:wp,
check:engines and check:licenses call its wp-scripts binary, as the
template and configs do.

Assisted-by: Claude Code:claude-opus-5-5
WooCommerce fires woocommerce_admin_field_{type} only for a type it
does not draw itself, so a field with a render callable and one of
WooCommerce's own types gets WooCommerce's control, and the callable
never runs. The is_drawn_by_forms() docblock said Forms draws every
render field, and neither the tab, the section nor limitation 4 named
the constraint.

The docblocks and limitation 4 say that a render callable needs a type
WooCommerce does not draw. The behavior stays as it is, by X ruling 15.

Assisted-by: Claude Code:claude-opus-5-5
Limitation 3 named only a network of thousands of sites as able to
outlast a web uninstall's time limit, though a single site whose
footprint holds many rows can too. Limitation 5 said core prints its
boot notices through bootstrap's Requirements, which the migration
runner's notices also use. OptionRows never said that it writes rows
without autoload, so a replace stops an autoloaded option autoloading.
OrderMetaBox said WooCommerce fires its save hook for every order type,
which holds only on the order tables. The layout tree gave the floors
as numbers, and the audit command did not say that npm's half audits
only runtime packages, of which the framework has none.

Each now states what the code does, and the layout tree names each
floor by the key that holds it.

Assisted-by: Claude Code:claude-opus-5-5
The core persona, which the core README excerpts, kept its migration
steps in a src/Migrations class and discarded its runner after
registering it. Convention 6 puts hook-free code such as migration
steps in includes/ as prefixed functions, and a plugin keeps its runner
so its other parts can reach it, as consumer-a and the template do, so
the README taught the anatomy the conventions reject.

The steps are acme_pc_get_migration_steps() and acme_pc_add_settings()
in includes/migrations.php, which functions.php loads, and the root
keeps the runner in a protected(set) property; the README excerpt and
the persona's PHPStan paths follow. The persona row also records the
runner, which fails when the composition discards it.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes ahegyes changed the title Fix/reaudit followups Close the v2 re-audit's framework findings Oct 29, 2026
The workflows called configs' reusables at 88710a7, and the root and
fixture locks installed the package at the same commit, several merges
behind configs' trunk. The reusables the framework calls ran setup-php
2.37.2 while the framework's own jobs ran 2.40.0, and the release and
documentation fixes merged since never reached the framework.

Every workflow ref and lock moves to 3cbd985. Between the two commits
the called reusables change only their action pins and comments, and
no input; the locks change only the configs entry.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes
ahegyes merged commit 1267430 into trunk Oct 29, 2026
28 checks passed
@ahegyes
ahegyes deleted the fix/reaudit-followups branch October 29, 2026 15:25
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.

1 participant