Skip to content

v0.44.0

Choose a tag to compare

@MustafaShaaban MustafaShaaban released this 10 Oct 00:54
· 137 commits to main since this release
21fb2dd

A release to stand on: everything built since v0.43.5, with no pull request and no issue left open behind it. A submission can be moved to a trash, restored and deleted for good, and the trash empties itself. Exports can be PDF. A form defined in code states its own wording, can be drawn with a site's own markup, and is challenged by reCAPTCHA, Turnstile or hCaptcha. The admin shows what is coming while it loads, and every action marks the control that was pressed. The role matrix on Access & Abilities can be saved, which it never could. CoreX → Releases reads a release package given to a site and says what it is; it installs nothing yet.

It is a minor release with a long Client impact, because it is many changes taken at once. Read that section before a site takes this. The ones that act on a running site without being asked: a submission in the trash for more than 30 days starts being deleted, with its files; a site with Turnstile or hCaptcha keys saved starts challenging; a site with the mail queue on and no Action Scheduler starts deferring its mail to WP-Cron; and a choice field refuses a value its options do not declare. Building needs npm ci, composer install (a new dependency, mPDF) and a rebuild of plugins/corex-config.

Added

  • A release can be unpacked beside a site, checked, and put in place by renames that are written down first (spec 107, slice 4, the first part). Only the folders a release owns are unpacked, into the installer's own folder, a request at a time; each is compared with what the package says it holds before any folder of the site moves. The swap is a list of renames in a journal: cut off half way, the next request finishes it, or puts every folder back when one will not move. Nothing calls it yet: no route, no screen (DECISIONS #300).
  • An installation has its maintenance answer and its way back (spec 107, slice 4, the second part). While a release is put in place WordPress itself answers visitors "briefly unavailable", from a .maintenance file that lets through only a request carrying the installation's key. A must-use plugin written beforehand puts every folder back when asked with that key, and uses nothing of CoreX, so it works when the new release does not load. Nothing calls either yet (DECISIONS #301).
  • The Submissions inbox shows what is coming while it loads (spec 108, slice 6, the last). The list is a table of placeholder rows until its first answer, and keeps its rows, dimmed, while a filter or a page is on its way; the count above it waits with them. An open submission is drawn as its sections until it is read. In the export dialog a count that has not arrived is a bar where the number will be, and the past exports are drawn as coming until they are known. WordPress's spinner is no longer used anywhere in the CoreX admin (DECISIONS #298).
  • Every action in the inbox marks the control that was pressed and cannot be sent twice: Undo, the bulk preview, the three confirmations, and in the pane: restore, mark read or unread, send reply, resend, open log, add note, status and owner. In the pane one request is out at a time, and the others wait until the submission has been read back.
  • CoreX → Releases: a screen that says what a site is running and reads a package given to it (spec 107, slice 3). It says which release the site is running and for whom, and what on the host stands in the way of installing one. A package is chosen and sent in parts, with how much of it the site holds as its progress; it goes on from where it stopped if the connection drops. Once it is there the screen states what it is before anything changes: its release, when it was built and for whom, what it would replace, how many files and how much room. A package the site will not take is refused in the site's own words. Nothing is installed from this screen yet, and the statement says so (DECISIONS #296).
  • A release package can be given to a site, in parts, and read there (spec 107, slice 3, the server's half). Five routes under corex/v1/releases: what is installed and what the host cannot do; a package received in parts, which goes on from where it stopped if the connection drops and is kept only if it is the file that was sent; and what a package on the site is, or why it is refused. A new ability, corex_manage_releases, holds them. There is no screen yet, and nothing here installs anything (DECISIONS #294).
  • A site can read a release package and refuse a wrong one (spec 107, slice 2). The checks a package gets where it lands, before anything of it is unpacked: it is a zip, it describes itself, it is for this site's client, this host can run it, it holds every folder it says it does, and nothing in it would be written outside the package. Each refusal is a sentence an administrator can act on. Also here: what stops a host installing at all, and the installer's own folder, wp-content/corex-releases/. Nothing on a site calls any of it yet; the screen is the next slice (DECISIONS #293).
  • Insights and the setup wizard show what is coming while they load (spec 108, slice 5). An Insights card shows a placeholder until the last results have arrived, where it read "Not run yet" about a check that may have been run an hour before; the widgets under the cards hold their place, where the screen was two cards and then seven. The setup wizard shows the step that is coming, where the form for a browser without scripts stood and was then swapped for the wizard (DECISIONS #291).
  • Forms and flows, and Email Studio, show what is coming while they load (spec 108, slice 4). The catalog of forms shows rows that are not there yet, where it showed "Loading forms…" over whatever it had; opening a flow shows the editor that is coming, where the catalog stayed, disabled, until the flow arrived. Email Studio shows a placeholder under its tabs until it has been read; choosing a template shows the editor that is coming, and marks the template that was chosen (DECISIONS #290).
  • The Data screen shows what is coming while it loads (spec 108, slice 3). The records list shows rows that are not there yet, the height of the rows that replace them, where it showed WordPress's spinner. A sort, a filter, a search or a page turn keeps the rows on screen, dimmed and out of reach, with the total above them; the search box asks when typing pauses, not for every letter. A record opens on the press, with a placeholder where its fields will be. The export history and the migration history show a placeholder, where each said "none yet" until its answer said otherwise (DECISIONS #289).
  • A button that is working shows the CoreX loader and cannot be pressed twice (spec 108, slice 2, first part). A ring in the button's own colour, in place of its label, at the same width. On the Notifications screen every action has it: each notification's own, Mark all as read on the screen and in the header drawer, and the preference boxes, which now wait for one another. The four buttons that showed WordPress's striped "busy" (export, the Data confirmation, the import dry-run, the migration confirmation) show it too. In Email Studio and in Forms and flows, where every button on the screen was disabled together while any request was out, the one that was pressed shows it and the others wait. The Data screen's import, migrations, export history and record dialogs, Blog Pro, access requests, the Security save, Insights, the setup wizard and the captcha test have it too, and none of them changes its label to "Saving…" any more (DECISIONS #285, #287, #288).
  • Notifications show what is coming while they load (spec 108, slice 1). The Notifications screen, its preferences and the header drawer show a placeholder in the shape of their content until the first answer, where each showed a sentence, and none shows "nothing here" before it has been told so. A filter, a view or a page turn keeps the list on screen, dimmed and out of reach, until its replacement arrives, where the list was removed for a line of text; the pager stays, and keeps the focus. A load that fails says so with Try again. Nothing moves for a person who asked for reduced motion. The pieces (CorexLoadable, CorexSkeleton) are shared, and the other admin screens are moved to them in later slices (DECISIONS #284).
  • A hosting package says what it needs and what it holds, and is one zip (spec 107, slice 1). corex-release.json gains the PHP and WordPress the release needs, the WordPress it was built with, every folder the release owns, and each folder's files, bytes and hash. npm run build:dist -- --zip writes corex-release-<client>-<version>-<built>.zip beside dist/. This is the ground for installing a release from the admin; nothing on a site reads it yet (DECISIONS #282).
  • The Submissions inbox has a trash (spec 105, slice 1). A submission is moved to it from its pane or as a bulk action, after a confirmation that says how many, and the notice that follows offers Undo. Trash, beside Inbox above the filters, lists what was moved there with who moved it and when. A trashed submission is read and nothing else until it is restored, from its pane or in bulk, exactly as it was. Reported from a client's production site, where a test lead could not be taken out of the inbox. Each action is in the submission's own history and, once, in the activity stream, with none of what a visitor submitted.
  • A trashed submission is CoreX's to keep. WordPress deletes a post from its trash for good after 30 days, and at once on a site set to skip the trash. A submission moved to the trash from the inbox is outside both.
  • The trash empties itself, on its own clock (spec 105, slice 3). A trashed submission is deleted for good after a number of days: 30 unless the site sets another under Submission retention, and never when it sets 0. The daily clean-up does it, with everything a deletion by hand removes, and records it in the activity stream as the trash's own doing. The Trash view says how long it keeps a submission and shows each one's day.
  • WordPress's own trash clean-up no longer deletes a submission. It deleted any trashed post 30 days on, with nothing recorded and a file uploaded with a submission left on disk. A submission trashed WordPress's way is taken onto CoreX's clock, with the date WordPress had.
  • A submission deleted by anything takes what is tied to it: a command, another plugin, or WordPress itself. Its uploaded files and the copies of its emails go with it.
  • A trashed submission can be deleted for good (spec 105, slice 2), from its pane or as the trash's bulk action. The confirmation says how many, lists what goes with a submission, and keeps its action disabled until a box is ticked. What goes: the answers with any hidden fields, campaign data and consent record; notes and history; files uploaded with it, from disk; and, where Email Studio is installed, the records and captured copies of emails sent about it. A site can now answer a request to remove what somebody sent.
  • Deleting for good needs a permission of its own. Administrators have it; anybody else gets it with the ability to run actions that cannot be undone, under Access & Abilities, or through the filter corex_submission_delete_permanently. Somebody who manages the inbox without it can restore from the trash and is told why they cannot delete.
  • A deletion is recorded once in the activity stream: who, when, how many, of which forms. Nothing the submissions said. A submission whose uploaded file cannot be removed from disk is left in the trash and counted in the notice, and the rest of the selection is still deleted.
  • Turnstile and hCaptcha show their widget on a protected form (spec 104, slice 4; closes #264), on a flow and on a form defined in code. It sits above the button; the form sends the token the widget gives; the provider's script loads only on a page with a protected form. A submission made before the visitor has passed is held in the browser with a message (DECISIONS #278).
  • A site draws a form its own way (spec 104, slice 3; closes #248). Override markup() on the form's class. It is handed the form's parts (attributes(), hidden(), status(), fieldAttributes(), error(), control(), label(), field(), submit()) and returns the form's HTML. A form built from them validates, submits, shows each error where the site put it and is challenged, with no script of the site's own. A form that supplies no markup is the stock form, byte for byte what it was. The forms guide has the contract and a complete example (DECISIONS #277).
  • A form defined in code can ask for the site's challenge (spec 104, slice 2; part of #264). Return ['captcha' => 'on'] from the form's protection(). With reCAPTCHA configured, the form carries a token field, its page loads the provider's script, and a submission is checked before any answer is judged. One that fails is answered 422 with code: "challenge_failed", stores nothing and runs no listener. Off by default, so no existing form changes (DECISIONS #276).
  • A form defined in code says what it reads (spec 104, slice 1; part of #248). Override submitLabel(), successMessage() and errorMessage() on the form's class. Each is empty by default, which is CoreX's own wording, so a form that states nothing reads "Send" and "Thank you — your message has been sent." as before. Stated wording is printed as text (DECISIONS #274).
  • An export can be a PDF (spec 103, slice 6), from the Submissions inbox and from the Data screen. "PDF document (.pdf)" is a third choice under Format. The document is A4, set wide. It opens with what was exported, how many records, and who exported it and when. Every page is headed by the site's name and the export's title, and signed at the foot as CoreX's: the CoreX logo, "Exported with CoreX", the copyright line, and the page's number out of how many. The owner, of what "signed off with the identity" meant: "pdf exported should has the corex logo on it as it has the copyrights of the tool". The heading row repeats on each page. More than eight columns are set out one record at a time, a question beside its answer. Arabic is shaped and joined, a right-to-left site's page reads right to left, and a phone number, a date or an English answer inside it keeps its own order.
  • A PDF holds up to 500 records. The dialog says so under the choice, and names the limit in place of starting when the choice holds more; the server refuses the same request. An export's file is written in one request. On the development machine five hundred rows took 6.6 seconds and 68MB, and a thousand took 17.3 seconds and 116MB.
  • The export's preview answers say which formats the server can write: a formats key with available and pdf_most_records. PDF is offered only where the library and PHP's gd and mbstring are present.

Changed

  • The retention panel's "Move to trash" is the inbox's trash (spec 105, slice 4). Each submission it moves says so in its history, with who ran it; the activity stream has one entry for the run; and a person whose view of the inbox is limited moves only what they can see. It trashed the WordPress way before: nothing recorded, anybody's submissions, and on a site that sets EMPTY_TRASH_DAYS to 0 a deletion on the spot (DECISIONS #297).
  • A PDF is written with the fonts that are installed. The PDF library stopped a document with "Cannot find TTF TrueType font file" at the first text in a script whose font file was missing. CoreX tells it which fonts are there, and it writes the rest in its default font.
  • The shared-host builder ships a few of the PDF library's fonts by default, and --pdf-fonts=all ships them all. The owner's first client site is updated by a zip uploaded by hand, and the whole set took that zip from 27MB to 71MB.
  • The build and test toolchain is @wordpress/scripts 36 (was 34). It no longer ships Jest, so Jest, its jsdom environment, the WordPress preset and the Babel transform are this repository's own dev dependencies, and npm run test:js runs wp-scripts test-unit-jest. The built JavaScript is byte-identical to the last build on 34 (DECISIONS #272).
  • The Data screen has one export dialog (spec 103, slice 7b), and it is the Submissions export's. "Export records" on the Records tab and the Export tab both open it: what to export with a count for each choice, the columns, the format, one action, the export's progress, and the file when it is ready. The Records tab had a WordPress modal that closed and said "The export was queued." The Export tab had a second form, with one scope, that said "Refresh history when the job completes." Neither handed over a file.
  • The Export tab's history reloads when an export was made. Its "Refresh" button is gone.

Security

  • The coming-soon page no longer prints WordPress's generator tag, RSD link, feed links or emoji loader. A page that says the site is not ready also said which WordPress it runs and where its remote-editing endpoint is, and offered feeds whose addresses the mode redirects back to the page. They are left out for a request that is served the page; a visitor let through to the real site, and the site after launch, get WordPress's head as before. Reported from a production site.
  • The hosting package no longer puts each plugin's README.md, composer.json and package.json in the web root. Anybody could fetch them and read what is installed and at which version. They are left out of CoreX's code and the site's; WordPress core's own are as WordPress ships them. Reported from a production site (DECISIONS #280).
  • With the login hidden, three well-known addresses handed the hidden address to anyone who asked. Signed out, /wp-signup.php, /wp-register.php and /wp-admin/customize.php each answered with a redirect to the custom login address. On a single site the first two only forward a visitor to the registration URL, and the Customizer sends a signed-out visitor to the login before CoreX answers the request; every one of those URLs is rewritten to the custom address. /wp-signup.php now gets the same "not found" page as /wp-login.php, byte for byte; /wp-register.php answers as any .php file that was never there does on that server; and the third is answered like the rest of the admin area. On a network /wp-signup.php is the public sign-up page and is left alone. Reported from a production site; the other two were found by probing. In every release since v0.34.0, on a site with "Hide wp-login.php and wp-admin" on. Hiding is obscurity, and the failed-login throttling was not affected.

Fixed

  • An answer with several choices reads as its choices on the Data screen. A multi-select or a checkbox group is stored as a list, and a record's detail and its summary printed it as JSON: ["brand-identity","motion-graphics"]. It reads brand-identity, motion-graphics, as the export already wrote it. An answer that was never given is blank, where it read null (DECISIONS #303).
  • The role matrix on CoreX → Access & Abilities can be saved (#313). "Preview changes" was a button with nothing behind it: an ability could be set to Allow or Deny for a role and nothing sent it. It now shows each ability with what it is and what it would become, says why the safety policy refuses a change that would lock somebody out, and applies what was read. The reason a locked ability cannot be edited is said in words, where its code was printed (DECISIONS #302).
  • A failed action in an open submission says why in the pane (#306). The reason was drawn above the list, which the open pane covers: the button stopped working and nothing was said. It is drawn in the pane, announced, and brought into view; above the list once the pane is closed.
  • A site's own form rule can be asked about a field left out of the request (#323). An optional field that a hand-written request leaves out was skipped with every rule on it, so a rule about several fields ("a phone number or an email address") was never asked when all of them were missing. A rule that implements RuleForAbsentValue is asked, with null. No rule CoreX ships is of this kind, and no other rule is handed an absent value.
  • Creating a flow cleared nothing and threw (#313). The "New flow" form was reset after its request through an event React had already let go of.
  • Moving a post in Blog Pro, or moderating a comment, blanked its editorial panel (#313), which then said the post had no editorial record until the page was loaded again.
  • Running a template's health checks emptied "Recent test sends" on Email Studio's overview (#313).
  • The setup wizard said nothing when a plan could not be read or applied (#313). The plan step counted "0 pages", and "Apply plan" came back as if it had not been pressed. Both say what the site answered.
  • The Data screen no longer offers to delete a form submission. The button was drawn because the source said it could delete, and pressing it was refused: the route needs a write adapter the source never had. A submission is removed in the inbox.
  • An inbox that could not be loaded said it was empty. A first load that failed drew "No matching submissions" under its error. The error stands where the rows would be, once, with a way to ask again.
  • The inbox said "0 accessible submissions" before it had been answered. Nothing is said of a count until there is one.
  • Switching to the trash showed the inbox's rows under the trash's headings until the trash arrived, and the other way round. Each view is drawn as coming until it is answered.
  • A past export could be downloaded or deleted twice by pressing twice in the Submissions export dialog.
  • The Newsletter, Bookings and Careers add-ons no longer run dbDelta() on every request. Each created its table on init: every front-end, REST and admin request of a site with one of them loaded WordPress's admin files and asked the database to describe a table that was already there. Each now declares its table to the migration runner the foundation tables use. A table is created from an admin page or cron, by wp corex migrate, or when a network gets a new site, and only when the version stored for the site differs from the code's. On a network, deleting a site also removes the add-on's table, where the add-on is loaded in the request that deletes it. What the add-ons register on init (the post type, taxonomies, the Data screen's tables, the email templates) is unchanged (DECISIONS #299).
  • A file sent through a form is stored, on every site. The attachment store called WordPress's wp_handle_upload(), which WordPress loads for an admin page and for nothing else. On a site where nothing else had loaded the admin's files, every submission that carried a file answered 500, "Request could not be processed." It worked where the Newsletter, Bookings or Careers add-on was active, because each creates its table on every request and loads the admin on the way. The store loads what it calls (DECISIONS #295).
  • A checkbox group on a form defined in code keeps the boxes that were ticked. The route cleaned the answer as one line of text, which empties a list, so the field was stored empty whatever the visitor ticked. A flow's form kept them; a form class did not.
  • A choice field refuses an answer it never offered. A select, radio, multi-select or checkbox-group declares its options and nothing compared the answer with them: a request written by hand could store any text as the visitor's choice. The answer is refused with the error choice, on forms defined in code and on flows (DECISIONS #292).
  • A form can be sent with its optional file left empty. A browser posting a form as multipart sends a part for every file input, chosen or not. The empty one was treated as an upload and the whole submission was refused with "The file could not be stored." A required file left empty said the same; it says the field is required now.
  • A pattern: rule with a comma in it works. pattern:^\d{2,4}$ was cut at the comma into two halves, neither of them an expression, and the rule refused every answer.
  • The forms guide and two comments no longer say a field can add a file type. A comment said a field's mime: widens what a form accepts, and another that max_size: raises the 10 MB limit. Neither does: both only narrow. The guide now lists the six types a file field takes and documents pattern:, mime:, max_size: and url, which it did not mention.
  • Insights says when its last results, or its widgets, could not be loaded. Both failed in silence: the cards went on reading "Not run yet", and the widgets were simply not there. The widgets offer Try again.
  • The setup wizard gives the plain form back when it cannot load. A script that could not read the wizard's state gave up without a word.
  • A disabled button in the CoreX admin is the button, dimmed. WordPress paints every disabled button a pale grey: a light box on the dark theme, on every screen that disables its buttons while it loads.
  • Email Studio on a site with no templates no longer takes the open form away on every save to say "Loading Email Studio…". Whether the studio had been read was guessed from whether it had any templates.
  • Choosing an email template twice in a row asked for both, and the slower answer was the one left in the editor. Templates are chosen one at a time.
  • A record's detail shows its fields. "View" on the Data screen drew the whole record as one field named "Record", holding a line of JSON: the route answers { record } and the screen read the answer itself as the record. The detail is also a CoreX dialog now; it was WordPress's own, unstyled, white on the dark theme.
  • The two totals above the records read "Fields" and "4", not "Fields4". The label and the number were drawn on one line with nothing between them.
  • The Data screen says when its list of sources could not be loaded, with a way to ask again. It said "No records yet." about a request that had failed.
  • A slower answer no longer replaces a newer one on the Data screen. Two searches or two page turns in quick succession could leave the first one's rows on screen.
  • A Data dialog no longer vanishes while its change is being worked out. New record, Edit record, Bulk edit and a record's Delete closed on the press and left nothing on screen until the confirmation appeared. Each stays, with its button working, until there is something to confirm.
  • Downloading an export or a rejection report twice by pressing twice. Both sent, and saved, a second file. The button waits for the first.
  • On the Migrations tab, "Refresh" and "Preview rollback" could be pressed while another request was out, and the confirm button showed "busy" whichever had been pressed.
  • Deciding two access requests quickly handed the first one's buttons back before its answer. Decisions are taken one at a time.
  • The setup wizard's "Next" said nothing while it fetched the plan, and asked again on a second press.
  • "Mark all as read" and a notification preference say when they failed. Both swallowed a failed request: the button did nothing, or the box went back to what it was, with no word of why. The server's reason is shown beside them.
  • Two notification preferences changed quickly no longer undo one another. Each change sends every category as it is on screen, so a second sent before the first had answered sent the first one back. The boxes wait for the change that is on its way.
  • A button that was both primary and destructive could not be read: the error colour as text on the action colour as ground, red on brass. It keeps the primary's ink. Seen in a capture of the new delete confirmation; the Data screen's migration rollback has the same combination and was unreadable the same way.
  • Running Spec Kit in a client repository no longer changes a framework file (#251, item 1). /speckit-specify wrote the feature it was working on into .specify/feature.json, which was tracked, and a hook rewrote a section of the root CLAUDE.md; npm run verify:framework reported both as drift, so a site's specs were written by hand. The state file is ignored and no longer in the tree, and the hook is off. A site's spec goes under the site by naming the directory: /speckit-specify SPECIFY_FEATURE_DIRECTORY=sites/<client>/specs/<work-item>-<slug>. A newly generated AGENTS.md says so (DECISIONS #279).
  • A reply sent from the Submissions inbox lost its line breaks. The reply is typed in a plain text box and sent as HTML, and nothing turned a line break into markup: three paragraphs reached the recipient as one block. A blank line is a new paragraph now and a line break is a line break. Read from the code while specifying the reply editor (spec 106, slice 0).
  • Two protected forms that share a name each ask the provider for their own action. The reCAPTCHA script looked the action up by the form's name; it reads it from the submitted form.
  • With the mail queue on, a send still waited for the mail server on a site without Action Scheduler (#271). The mail_queue flag deferred a send only where Action Scheduler was installed, and CoreX does not ship it, so a form's response waited for both of its notification emails: three to four seconds on the site that reported it. WP-Cron takes the queue now where Action Scheduler is absent. The message is kept in an option of its own, a single cron event carries its id, and WordPress is asked to run due events as the request ends. Action Scheduler is still used wherever it is installed, and is what a site that sends to a long list wants (DECISIONS #273).
  • A mail queue dispatcher a site bound itself was never run. The queue's hook acted only when the bound dispatcher was the Action Scheduler one. It calls handle() on whichever is bound.
  • A Data export of Form submissions left every answer's column empty, in CSV and in Excel. The dialog offered a column for each answer (Email, Name, Message) and the file had the heading and nothing under it: a real one read "2026-10-08 13:28",corex-inbox-e2e,"email: someone@example.com",,,. The export read each cell from the Records table's row, which holds a date, a form and one summary. A source now hands an export its own rows, with a value under every field it declares.
  • A Data export of ticked Form submissions also lost the "Submission" column. Ticked records were read as the detail view shows them, which has no summary. Ticked, filtered and "everything" are read the same way now.
  • Two answers whose keys differ only in capitals were offered as two columns with one key. They are one column, holding whichever of the two a submission has.
  • A Data export stopped when a record arrived in the source while it was being written, with "Bounded job counters are inconsistent." The last batch then held more than what was left to export, and the job refuses to count past its total. A batch is cut to what was counted when the export was asked for, as the Submissions export's has been. On a site that receives submissions while somebody exports all of them, that was every such export.
  • In the export dialog, a choice's name was smaller than the line describing it: 13px, WordPress's size, above a line set to 14px. The dialog has its own text size. In the Submissions export since v0.43.2.

Client impact

  • A site that patched SubmissionsSource so a list answer reads as a list can drop the patch. The framework does it now, for the record's detail and the summary. Take the framework's file when the merge reports a conflict there.
  • Corex\Config\Data\DataController is removed. It was bound in the container and registered no route since DataManagementController took the Data screen's routes over; nothing in CoreX called it. Code of your own that resolved it has to use the registered controller's routes under corex/v1/data.
  • A form rule of your own that looks at other fields can implement Corex\Forms\Validation\RuleForAbsentValue in place of Rule to be asked when its optional field is left out of a request. Nothing changes for a rule that does not.
  • On a site that sets EMPTY_TRASH_DAYS to 0, the retention panel's "Move to trash" no longer deletes on the spot. The submissions go to the inbox's Trash and are deleted for good after Keep in the trash for days (30 unless the site set another). A site that relied on the immediate deletion deletes them from the Trash view, or sets a short number of days.
  • Code of your own that trashed a submission through CoreX's reader has to change. WpSubmissionsReader::trash() and trashForRetention() are gone, with their lines in SubmissionsReader and SubmissionRetentionStore; SubmissionRetention::prune() and applyIds() take the person's SubmissionAccessScope first. Use SubmissionTrashService::trash(). The submissions data source answers delete: false, and its delete() returns false.
  • A new admin screen, CoreX → Releases, for whoever holds corex_manage_releases. It reads and receives; it installs nothing yet. On a network only a super administrator sees it, on the main site.
  • A package the site refuses is removed from it. A zip in wp-content/corex-releases/incoming/ that is read and refused (not a package, another client's, a PHP or WordPress the host does not have) is deleted, whether it was uploaded or put there by hand. The refusal says so. Keep your own copy of a package.
  • A site with Newsletter, Bookings or Careers: nothing to do where somebody opens wp-admin or cron runs. The tables are already there. The first admin page or cron run after the update checks each once and stores three options, corex_newsletter_schema_version, corex_bookings_schema_version and corex_careers_schema_version, one per add-on that is active. Deploy steps that already run wp corex migrate do the same.
  • An add-on activated from the command line has no table until wp corex migrate runs, or until the first admin page or cron run. Before this, any request created it. A provisioning script that activates Newsletter, Bookings or Careers with wp plugin activate and then writes to its table has to run wp corex migrate in between. The framework's own scripts/setup-wordpress.ps1 and CI already do.
  • On a network, an add-on that is active on one site only is migrated by that site. Its own admin pages and cron do it. From the command line use wp corex migrate --url=<site>: --network started from a site without the add-on does not know the add-on's table.
  • Code of your own that relied on WordPress's admin functions being loaded on the front end must load them. On a site with one of these add-ons every request used to load wp-admin/includes/. It no longer does. A call to get_plugins(), wp_handle_upload(), dbDelta() and the like outside wp-admin needs its require_once first. The framework's own code was checked against every function WordPress defines there.
  • A table a site creates itself is not changed by this. Migrator::create() works as it did. A site plugin that calls it from a hook pays the same price on every request that the add-ons did; guard the call with a version option of your own. Registering a component with SchemaRegistry from Corex::onReady() reaches wp corex migrate and a network's new sites, and not the check an admin page runs, which has finished by then.
  • A site that loads wp-admin/includes/file.php itself, to make a form's file upload work, can stop. The framework loads it where it is needed. Leaving the workaround in place does no harm.
  • A new ability, corex_manage_releases. Whoever manages the CoreX admin holds it. A role given only "Manage operations and security" does not: installing a release is not part of that. On a network it also needs a super administrator, on the main site.
  • New routes: GET corex/v1/releases, GET and POST corex/v1/releases/uploads/{hash}, POST …/uploads/{hash}/complete, POST corex/v1/releases/inspect. A package they receive is kept in wp-content/corex-releases/incoming/, a new folder, closed to a web server. A backup that copies all of wp-content will copy it.
  • A form whose markup you wrote yourself must send the option values it declares. A choice field's answer is compared with the keys of its options. The stock form and a flow's form print those keys, so nothing changes for them. A hand-written select, radio or checkbox group whose values differ from the declared keys (a label where the key was meant, an "Other" the field does not declare) is refused with choice after this. Declare every value the markup can send, or declare no options on the field: a field without them is not compared.
  • A script of your own that reads a form's errors can meet a new one, choice. The form's own messages carry a sentence for it: "Choose one of the options offered."
  • Nothing to do for the Notifications loading states. No setting, route or stored value changes. A browser test of a client's own that waited for "Loading notifications…" or "Loading preferences…" to be hidden will still pass, for the wrong reason: the sentence is no longer drawn. Wait on .corex-loadable[data-corex-state="ready"].
  • npm run verify:dist refuses a package that was changed after it was built. A package measures its own folders now. A deploy step that edits dist/ after build:dist (adding a file to a plugin, say) has to do it before, or build again.
  • A permanent delete cannot be undone, and takes files off the disk. It is offered only in the trash and only after an acknowledged confirmation. Export files made earlier are not rewritten: they are named in the confirmation and expire after 30 days. Rows of the mail log are not tied to a submission and are not removed.
  • New route: DELETE corex/v1/submissions/{id}, for a trashed submission, answered 403 for somebody who may not; delete among the trash's bulk actions, whose result gains failures. The list's answer gains can_delete_permanently.
  • New seams: Corex\Mail\SubmissionEmailRecords (the email add-on binds its own; the default removes nothing) and Corex\Email\Studio\EmailAttemptRemoval. Constructors changed: SubmissionAccessScope takes canDeletePermanently last, and SubmissionTrashService takes a SubmissionEmailRecords.
  • Trashed submissions start being deleted. After taking this, a submission that has been in the trash more than 30 days is deleted for good at the next daily clean-up, with its files. That includes submissions the retention panel moved to the trash before: they keep the date WordPress recorded. A site that wants to keep them sets Keep in the trash for days to 0, or restores them, before the clean-up runs. WordPress would have deleted the same submissions at the same age; the difference is that the files go too and it is recorded.
  • New option corex_trash_submissions_days; the list's answer gains trash_days; a new store in the retention sweep, submission_trash. RetentionController, SubmissionsInboxScreen and SubmissionQueryService each take a SubmissionTrashRetention.
  • New routes: POST corex/v1/submissions/{id}/trash and …/{id}/restore; view=trash on the list; trash and restore among the bulk actions. A change asked of a trashed submission is answered 409, "This submission is in the trash. Restore it to change it.", where it was 404.
  • A submission's record has five more fields: trashed, trashed_at, trashed_by, trashed_by_name, trashed_via.
  • Constructors changed, for code that builds these itself: SubmissionBulkService and SubmissionControllerServices each take a SubmissionTrashService.
  • The admin bundle and the inbox's stylesheet changed. build/ is git-ignored: rebuild plugins/corex-config after taking this.
  • A package built after this holds no README.md, composer.json or package.json under wp-content/plugins, themes or packages. A site already on a host keeps the ones it has until they are deleted there: unpacking a new package over an old one removes nothing. wp-content/vendor/ is unchanged and should be refused by the web server.
  • .specify/feature.json leaves the tree. A client repository that takes this stops tracking it; a copy a session wrote stays on disk, ignored. If the merge reports it as changed on both sides, keep the deletion. The /speckit-* commands can then be used for a site's specs: see the entry under Fixed. An AGENTS.md generated earlier still says to write specs by hand and is the site's to correct.
  • A site with Turnstile or hCaptcha selected and keys saved starts challenging when it takes this, and refuses a protected form's submission that carries no token. Until now such a site challenged nobody and refused nothing. Submit one protected form after updating: the widget was checked against each provider's own script on a test page, not on a site. reCAPTCHA is unchanged.
  • A hand-drawn form protected by Turnstile or hCaptcha needs $parts->challenge() in its markup, before its button. It prints nothing for any other form, so it is safe to add always.
  • The provider test no longer answers no_widget, and the admin no longer lists a captcha.widget gap. FormChallengeContextFactory::driverPlacesNoWidget() is gone.
  • A site with its own form renderer can replace it with Form::markup(). A renderer that reproduced CoreX's attributes by hand keeps working; it is no longer needed, and it is what breaks when those attributes change.
  • FormBlockRenderer's constructor takes two more optional arguments, and ProtectionStage's takes a SubmissionChallenge, for code that constructs either itself. Both are built by the container on a site that does not.
  • A site with mail_queue on and no Action Scheduler starts deferring its mail when it takes this. A message leaves on the next WP-Cron run, not inside the request. A site that sets DISABLE_WP_CRON must run wp-cron.php from its own scheduler, or its mail waits; with the flag off nothing changes.
  • Corex\Email\Queue\MailQueueDispatcher has two more methods, name() and handle(), and the constant HOOK. A site's own dispatcher has to add both methods. toArray() and fromArray() moved from ActionSchedulerDispatcher to MailRequestPayload.
  • A site that hides its login should take this, and may treat its login address as known if the site has been public: anyone who asked for /wp-signup.php was told it. Choosing a new address on the Security screen after updating is the remedy.
  • /wp-signup.php and /wp-register.php answer 404 on a single site with the login hidden. A link or a server rule that sent visitors to either has to point at the registration address WordPress gives (wp_registration_url()), which still works.
  • CoreX cannot hide /wp-activate.php, /wp-admin/install.php or /wp-admin/upgrade.php: WordPress runs them without plugins. They do not name the custom address. A site that wants them answered differently needs a rule in its web server.
  • Building needs Node 22.22 or later (or 24.15+); it was 20. @wordpress/scripts 36 declares it. Run npm ci after taking this: the lock file changes throughout. A client repository's own workflow already asks for Node 22.
  • A site whose own tests run through wp-scripts test-unit-js has two lines to change. That command runs Vitest now, and @wordpress/scripts/config/jest-unit.config.js is no longer shipped. In the site's package.json, call wp-scripts test-unit-jest. In its Jest config, replace the require() of that file and the spread of it with what the root jest.config.js has: preset: '@wordpress/jest-preset-default' and a transform that names babel-jest with @wordpress/babel-preset-default. Both are installed at the repository root, where a site's config resolves them. A site that calls jest itself is not affected.
  • A new production dependency: mpdf/mpdf ^8.3 (GPL-2.0-only), with its own dependencies. Run composer install after taking this. It adds about 94MB to vendor/, 88MB of it fonts.
  • The shared-host package keeps the PDF library's fonts for Latin, Greek, Cyrillic, Hebrew and Arabic-script text, and leaves the rest out: 33MB zipped and 117MB unpacked, measured on the framework alone, where every font made it 71MB and 191MB. A PDF from such a site prints text in any other script (Chinese, Japanese, Korean, Thai, the Indic scripts) as empty boxes; the workbook and the CSV hold it as it is. npm run build:dist -- --pdf-fonts=all keeps every font. corex-release.json says which was built, as pdf_fonts. A site installed with Composer has every font.
  • PdfExportWriter takes a third argument, PdfFonts, for code that constructs it.
  • The shared-host builder removes from the packaged vendor/ what a package may never hold (.github, tests, .git, node_modules and the rest of its forbidden list). Composer installs a package as its author shipped it, mPDF ships a .github, and the package was refused for it. A production package that loads code from a directory named tests would lose it; none does today, and the verifier already refused such a package.
  • PDF needs PHP's gd and mbstring. Without them CoreX works as before and does not offer PDF. mPDF keeps a cache of font data in uploads/corex-private/exports/pdf-working/, beside the exports and behind the same protection.
  • Signatures changed in Corex\Config\Export, for code that builds exports itself: ExportDocument takes a third argument, the facts a document opens with; ExportWriters is constructed with the PDF writer and has available() and describe(); SubmissionExportFiles and DataExportFiles take the new document builders. Nothing changes for a site that only uses the screens.
  • A data source of your own that declares an export has to implement ExportableDataSource (Corex\Config\Data), or the Data screen stops offering "Export records" for it. It was enough to implement QueryableDataSource and FieldAwareDataSource. Add exportRows(DataQuery $query) and exportRowsOf(array $ids), each returning rows keyed by field key with a value under every key fields() declares. Where your fields are your table's columns, exportRows() can return query(). Managed tables and Form submissions need nothing: CoreX implements it for both.
  • A Form submissions export from CoreX Data made before this has empty answer columns. Export again; the old file is not rewritten.
  • The Data screen's export looks and behaves differently: a dialog titled "Export ", which waits and saves the file, where there was "Create export" and "Queue export". Anything that drives that screen by its labels has to follow.
  • The export dialog's styles moved from plugins/corex-config/assets/submissions-admin.css to plugins/corex-core/assets/css/corex-admin-shell.css. The class names are unchanged.
  • .corex-data__column-picker and .corex-data-models__columns are no longer styled: nothing CoreX draws uses them.
  • The Data explorer's createExport is gone, with admin/data/ExportDialog.js. They are internal to the admin bundle.
  • The admin bundle changed. build/ is git-ignored: rebuild plugins/corex-config after taking this.