Skip to content

Store settings in rows of their own at every scale - #76

Merged
ahegyes merged 12 commits into
trunkfrom
refactor/settings-at-every-scale
Nov 14, 2026
Merged

ahegyes merged 12 commits into
trunkfrom
refactor/settings-at-every-scale

Conversation

@ahegyes

@ahegyes ahegyes commented Nov 14, 2026 •

Copy link
Copy Markdown
Owner

Settings now take one shape at every scale: each setting is its own autoloaded option on every host (X ruling 416).

  • OptionsPage stores each field in its own option through the Settings API. The definition gives the page's tabs as slugs and titles and each tab's fields apart, through get_tab_fields() and the {root}/options_page/fields filter, so the page asks only for the fields of the tab it shows or saves. Each tab posts under the settings group {page id}_{tab slug}, only the posted tab's fields register, and a field whose id is outside the plugin's prefix is left out with _doing_it_wrong(), since the page's capability would otherwise write any option a filter names.
  • Number fields are held to the min, max and step in their custom_attributes on every host, without a validate per field: the grid is checked exactly on the decimal digits, every accepted number stores as one canonical text, and a rejection names the bound it missed. The WooCommerce bridge applies the same rules on the classic form and over wc/v1–v3 REST and gives WooCommerce-drawn number fields step="any" (X rulings 420, 429 and 430).
  • Uninstall reads its consent from its own option only; the consent's key argument is gone (X ruling 417).
  • consumer-a and the personas store their settings in whole-named rows, which consumer-a and the core persona seed from stored-form defaults by one loop; consumer-a offers acme_ca_is_reports_enabled() and hooks after its includes (X rulings 413–415, 418 and 428).

AGENTS.md's file map, convention 8 and limitation 4 and the core, settings and woocommerce READMEs describe the rows and the number rules. Limitation 4 names the two places the rules do not reach: WooCommerce's rest-api-v4 routes, a feature it ships off, and a field with a step and no min, whose steps the save counts from zero while the browser counts them from the value the control renders with.

Testing: Integration 1,456 tests on WordPress 7.1.4 with WooCommerce 11.3.0 and on WooCommerce's 11.4.0-dev, the multisite and without-WooCommerce groups, the PHP 7.4 suite, prefer-lowest on consumer-a, the end-to-end spec, Unit, PHPCS, PHPStan and both audits.

Refresh the root and fixture Composer locks and the npm lock. The
Composer locks take phpunit/phpunit 13.4.7 and roave/security-advisories;
the npm lock takes @playwright/test 1.65.0 with its playwright packages,
and patch releases of glob, minimatch, sass, schema-utils, chrome-launcher
and the browser data packages, all past the seven-day cooldown, which
holds back Playwright 1.65.1.

The cooldown made npm update move @babel/helper-create-regexp-features-
plugin back from 7.29.10 to 7.29.7, so its entry keeps trunk's 7.29.10.
WooCommerce 11.3.0 is still the newest stable release, and WordPress's
core pins stay at their branches' newest patches.

Assisted-by: Claude Code:claude-opus-5-5
consumer-a keeps its uninstall consent inside one array row, which its
uninstall reads through the Uninstaller's consent key, and its Reports
gate reads a second setting under a composed name that Options does not
know. Every plugin stores each setting in its own autoloaded row, under
a whole name on Options, seeded from stored-form defaults (X rulings
414, 416, 418 and 428), offers integrators a narrow accessor for a
setting (X ruling 413), and hooks after its includes loop, so that an
include that throws leaves nothing of it hooked (X ruling 415).

The rows expect the first request to store each default in its own
autoloaded row and to keep a stored value, the accessor to read what the
Reports gate reads, each seeding step to carry the fingerprint of the
key set it stores, an include that throws to show the build notice with
the plugin unbooted, and uninstall to read the consent from its row.

Assisted-by: Claude Code:claude-opus-5-5
consumer-a's uninstall consent and its reports switch become two
options named whole on Options, each holding its own value, with
stored-form defaults that the seeding step adds as autoloaded rows,
leaving any stored value as it is; the step reads the defaults' keys,
so a release that adds settings lists it again. Options reads a setting
as a boolean, falling back to its default read the same way, since
PHPStan reads a final class's constant by its literal values. The
Reports gate reads its switch through Options, and includes/reports.php
offers the same reading to integrators as acme_ca_is_reports_enabled().

functions.php hooks after its includes loop, so an include that throws
reaches the entry's catch with nothing hooked. The uninstall consent
names its own option, the footprint's network option is written whole,
and the PREFIX and FOOTPRINT docblocks say what each covers.

Assisted-by: Claude Code:claude-opus-5-5
The core persona keeps its greeting and its uninstall consent in one
array row, which its uninstall reads through the Uninstaller's consent
key, while every plugin stores each setting in its own row under a whole
name (X rulings 414, 416 and 418), and core's README excerpts the
persona's uninstall. The rows expect the first request to store each
default in its own row and a consenting uninstall to remove both.

Assisted-by: Claude Code:claude-opus-5-5
The core persona's greeting and uninstall consent become two options
named whole on Options, with stored-form defaults the seeding step adds
as autoloaded rows, leaving a stored value as it is. The greeting reads
through a generic string reader by key in place of a reader for that one
setting, the uninstall consent names its own option, and the footprint's
network option is written whole; core's README excerpts follow.

Assisted-by: Claude Code:claude-opus-5-5
The Uninstaller's consent took a key, under which it read the consent
from an array row, a shape that only a settings group stored in one
option needs. Every plugin now stores each setting in its own row, the
one plugin that grouped its consent moves to flat rows at its port, and
consumer-a and the core persona already read their own rows (X ruling
417), so the key, its shapes and its rows go. An array stored under the
consent option no longer consents, which a retaining row holds, and
core's README names the two ways a site consents.

Assisted-by: Claude Code:claude-opus-5-5
Forms checks only a number's grammar, so a field stores 2.5 under a step
of 1, 0 under a min of 1, and 3.0 or 1e3 as typed, which Options' integer
reader then reads as its default; on a WooCommerce tab a number field
without validate stores WooCommerce's text unchecked, and one without a
step renders under the browser's step of 1 while Forms renders any. The
framework holds every number field to the min, max and step it declares,
on both hosts, without a validate per field (X rulings 420 and 429).

The rows expect a number to store as one canonical text, a value below
min, above max or off the grid from min or zero to be rejected with a
message naming what it missed, the grid checked exactly on its decimal
digits, malformed attributes read as the browser reads them, a number
too large to check rejected, and the WooCommerce bridge to apply the
same rules on the classic form and over REST and to render a number
field without a step with step any. A translated message that names no
value is returned as it is, percent signs included.

Assisted-by: Claude Code:claude-opus-5-5
Forms' number sanitizer reads the min, max and step the control renders
with and rejects a number below min, above max or off the grid the step
lays from min, or from zero, naming what it missed; with no step, the
control's step any leaves only the bounds. Attributes read as the
browser reads them, so an unparseable bound is ignored and a step that
is neither any nor positive counts as 1. Every accepted number stores as
one canonical text, without an exponent, a plus sign or padding zeros,
so a whole-number step stores integer text that the integer readers
read. The grid is checked on scaled integers built from the decimal
digits, since floating point refuses 0.3 on a 0.01 grid, and a number
those integers cannot hold, or one a float rounds to zero, is rejected.
Only a rejection message that names values goes through sprintf(), so a
translation's percent sign cannot throw.

The WooCommerce bridge runs the sanitizer for a number field without
validate, on the classic form and over REST alike, and gives the number
fields WooCommerce draws Forms' default step, as ProductDataTab does.
Limitation 4 names the one save path the bridge does not see, WooCommerce's
rest-api-v4 routes (X ruling 429), and the field with a step and no min,
whose steps the save counts from zero while the browser counts them from
the value the control renders with (X ruling 430).

Assisted-by: Claude Code:claude-opus-5-5
OptionsPage stores each tab's fields as one array option, so a plugin
keeps two storage shapes, one per host, with their own constants,
readers and consent forms, a setting cannot move to a WooCommerce tab
without a data migration, and every admin request builds every tab's
fields. Pages store one row per field, as WooCommerce's tabs do, ask
only for the shown or posted tab's fields, and register only the posted
tab's fields on options.php; because a row can name any option, a field
id outside the plugin's prefix or the helper id pattern is dropped with
a notice (X ruling 416).

The rows expect the definition to give a tab's fields on their own,
through get_tab_fields() and a fields filter that contributes them,
each field registered in the tab's group typed string or array, nothing
registered outside options.php, a dropped field neither rendered nor
stored, a tab without a field to save rendered without a form, and every
save, rejection and rendering row on the field's own row. The rows for
an id holding an entity and for a stored id the definition dropped go,
since the pattern refuses the first and a row of its own keeps the
second.

Assisted-by: Claude Code:claude-opus-5-5
The settings persona and consumer-b store their page's fields in one
array option, and the persona validates its limit by hand, while pages
store each field in its own row (X ruling 416) and Forms holds a number
to its min and step (X ruling 420). The rows expect the persona's limit
in acme_ps_limit as canonical text, refused below its min with Forms'
message, and consumer-b's greeting posting under its own id.

Assisted-by: Claude Code:claude-opus-5-5
OptionsPage stored each tab's fields as one array option and built every
tab's fields on every admin request. Its definition now gives the page's
tabs as slugs and titles and each tab's fields apart, through
get_tab_fields() and the {root}/options_page/fields filter, which passes
the tab's slug and the page's id; the page asks only for the fields of
the tab it shows or the options.php request saves (X ruling 416).

Each tab posts under the settings group {page id}_{tab slug}, whose
capability the page sets, and only the posted tab's fields register, as
string or array settings without a REST schema (X ruling 328), each
sanitized through Forms on its own and keeping its stored value where
the form posted nothing or the field rejects it, its rejection filed
under its id. The page renders the shown tab from its primed rows, each
control posting under its field's id, which Forms' rows take with an
empty prefix, and renders no form for a tab without a field to save,
whose group options.php would refuse. A field whose id does not start
with the plugin's prefix, or is not a valid helper id, is left out with
a _doing_it_wrong() notice, because the page's capability would
otherwise write any option a filter named, a core option such as
default_role among them; the label no longer needs its id escaped, since
such an id holds no entity.

The settings persona and consumer-b store their fields in rows, the
persona's limit held to its min and step by Forms in place of its own
validator; the settings README, which asks a number field with a step to
state its min (X ruling 430), AGENTS.md's file map and convention 8
describe the rows.

Assisted-by: Claude Code:claude-opus-5-5
The woocommerce and settings personas compose their option and meta
names from their prefix, which the package READMEs copy, while a stored
name is written whole, so that a contract string can be found and an
edit of the prefix cannot rename stored data (X ruling 414). The names
are unchanged.

Assisted-by: Claude Code:claude-opus-5-5
@ahegyes ahegyes changed the title Refactor/settings at every scale Store settings in rows of their own at every scale Nov 14, 2026
@ahegyes
ahegyes merged commit 8db8d53 into trunk Nov 14, 2026
28 checks passed
@ahegyes
ahegyes deleted the refactor/settings-at-every-scale branch November 14, 2026 11:38
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