Skip to content

30 August 2026

5 changes:

  • Twenty-five migrations dated 2020 that were written in 2026
  • A full cache flush never removed a directory
  • deferredwrites moves to pramnos, and the schema builder learns how
  • Web push had no sender in the authentication flow
  • The advanced sign-in rules had no field on any screen

Twenty-five migrations dated 2020 that were written in 2026

Found by pulling the thread on yesterday's two. emailoptouts and emailtracking carried the 2020_01_01 baseline prefix and were written on 28 and 29 August; renaming them raised the question of how many others there were.

git log --diff-filter=A against every 2020_01_01_* file says: twenty-five more, added between 11 May and 10 August. The genuine baseline is 3 May.

Why it matters

2020_01_01 is not a date, it is a marker: this predates the migration system. An installation that predates it sets migration_cutoff = 2020_01_02_000000 and skips everything below. So on such an installation none of these twenty-five run — the authserver schema, its roles, permissions and user-roles tables, the organizations table, OAuth2 device codes and user consents, the broadcast events table, the delayed jobs table, the notifications table — and nothing reports that they were skipped.

Why the rename is safe, and how that was established rather than assumed

Migration::getSlug() keeps only what follows the timestamp, and the schemaversion key is the slug. So renaming the prefix does not make an already-applied migration look unapplied: no deployment re-runs anything.

Ordering was the real question. The runner sorts by dependency graph first, $priority second, and the filename timestamp only as the final tiebreaker — so a rename can only matter between two migrations with the same feature, the same priority, and no dependency path either way. Twelve such ties exist. Each was checked for a real relationship — does either one reference a table, view, schema or function the other creates — and none does; the one pair that matched on a string turned out to share only a word in a docblock.

Then it was tested rather than reasoned about: all 85 framework migrations run from scratch against an empty PostgreSQL database, in the old order and in the new one. 85 ok, 0 failed, both times.

Two tests that hard-coded a filename

Both broke, and both were right to be fixed rather than updated: DelayedQueueDatabaseTest require_onced a migration by its timestamped path, and PermissionStoreMigrationLocationTest listed four of them in a data provider. They resolve by slug now — the timestamp is metadata about when and whether a migration runs, and a test asserting where a migration lives has no business depending on it.

The remaining forty 2020_01_01 files are the real baseline of 3 May and stay as they are.

A full cache flush never removed a directory

Chased from two symptoms that looked unrelated: four DevPanel cache tests costing about 2.5 seconds each while their twenty-one siblings cost 0.02, and one intermittent failure inside User::deleteuser() — at the line that calls $database->cacheflush('userlist').

Measured rather than guessed. getStats() 1.28s, getAllItems() 1.26s, the flush 1.28s — three operations, the same number each, which is the shape of a fixed cost rather than of work. The walk they share reported zero files and took 1.2 seconds.

var/cache held 8,589 empty directories, and about a thousand more per day of running the suite.

Where they came from

clear('') — flush everything — passes the cache root as its path, and finishes with cleanEmptyDirectories($path). That method walks upward from a directory and its first line is if ($dir == $this->cacheDir) return; — the guard is right, the root is ours to keep, but it means a full flush hands it the root and it returns immediately.

So every file went and every directory stayed. Nothing looked wrong: the entries were gone, every read missed, every count was right. What accumulated was one empty directory per category the installation had ever cached under — and Database::columnCacheCategory() makes one per table, so a database that creates and drops tables grows them without limit, and every getStats(), getAllItems() and clear() walks the lot.

The fix was already half-written

pruneEmptyDirectories() exists, walks downward, and is called by the expiry sweep — added for the same bug found from the other end, at 3,064 directories on a container. clear() was never changed to use it. It does now, for a full flush; a targeted clear keeps the cheap upward walk, which is correct and must not take its neighbours with it.

What it was costing

The DevPanel cache tests went from 11.9 seconds to 0.68, and the whole suite from 2:44 to 2:07. After a full run the cache directory holds 18 directories rather than 8,589.

And the intermittent cacheflush() failure in UserTest is explained by the same walk: a second-long traversal of thousands of directories, racing whatever else the suite was doing to them.

deferredwrites moves to pramnos, and the schema builder learns how

The last of the framework's own tables sitting in public. The push tables and the email suppression and tracking tables moved earlier in the week; this one was left because it had been deployed since 12 August, which makes it a data migration rather than a rename.

It turned out the framework could not express the move at all. renameTable() compiles to ALTER TABLE … RENAME TO on PostgreSQL, and that takes a bare name — handing it pramnos.deferredwrites is a syntax error, not a move. The statement is SET SCHEMA, and nothing wrapped it.

SchemaBuilder::moveToSchema() does now, and it answers false rather than raising in the three states a migration actually meets: the table is not there, it is already in the target schema, or something is already at the destination. The third is the one worth checking — a half-finished manual move leaves a table of the same name in both schemas, and overwriting the destination would destroy the rows somebody had already moved. The source is left untouched.

SET SCHEMA is a catalogue update, so no row is copied whatever the table holds — and this one is a spool that is drained continuously and holds almost nothing anyway.

Verified with a row in it

Not just "the table is in the new schema": a witness row was written to public.deferredwrites, the migration run, and the row read back through DeferredWriteQueue::TABLE from pramnos.deferredwrites with its JSON intact. Then the migration was run again — Nothing to migrate.

The migration that creates the table now creates it in pramnos directly, so a fresh installation never puts it in public and the move is a no-op there. Callers address it through DeferredWriteQueue::TABLE and none of this reaches them.

Web push had no sender in the authentication flow

Asked to check that authentication and web push work together. They did not.

Push shipped complete — a VAPID pair, subscriptions, a channel, a log, a service worker, a browser script, a soft prompt, an administration screen — and nothing in the authentication flow ever sent one. Every Auth\Notifications class declared via() => ['mail'] and none implemented toPush(). The only senders were the mass-message screen and the per-user message screen: an operator typing something by hand.

So the notifications a push is actually for went by email alone.

Two of them now send one, and two deliberately do not

Push
New sign-in from an unfamiliar device yes the mail is read when the mailbox is next opened
Security change — password, address, factor, passkey yes if the address changed, the mail goes to the one an attacker now controls
Sign-in code no a credential
New-device auth link no a credential

The line is between an alert and a credential. A code delivered to a subscribed browser is delivered to whoever holds that device — the person the second factor exists to stop. Mail is the deliberate second channel for those and stays the only one.

The existing docblock on NewSignInNotification argued the database channel out on the grounds that an in-app warning is seen by whoever is currently signed in, who in the case worth warning about is the wrong person. Push had to answer that, and does: a browser receives a push only because somebody granted permission in it earlier, so an account's subscriptions are the owner's devices — not the one the sign-in just happened on. Same property as mail, arriving in seconds.

Neither push carries a link. A notification that appears unprompted and offers a button to secure your account is the shape of the attack it warns about.

An address change sends two mails, to the old address and the new. Only the copy to the new address carries the push: both are the same account, and the devices would otherwise get the same warning twice.

The advanced sign-in rules had no field on any screen

Reported as «είχα ζητήσει πιο advanced κανόνες ειδοποιήσεων ασφαλείας, αλλά δε βλέπω πουθενά κάτι» — and they were there, and wired. SignInRisk collects the signals that are hard to explain innocently: a country the account has never been used from, two countries at once, a sign-in straight after a burst of failed guesses. LoginFlow consults it whenever the trigger is suspicious.

No theme rendered a field for the trigger. The only way to reach any of it was to write a settings row by hand. A rule nobody can enable is a rule nobody has.

And a live bug underneath

The action — notify, auth link, require a factor, require a passkey — had a field in the tailwind theme and in neither of the other two. The controller wrote it on every save, defaulting to notify. So on those themes a settings save silently reset require_2fa to notify: the strict reading quietly becoming the permissive one, which is the direction that matters, from an operator changing something unrelated.

The same shape as devpanel.mount and devpanel.min_usertype, which were lost the same way. The pattern is $request->get($key, $default) answering the default for a field the form never rendered, and the controller writing that back.

saveChoice() now carries the __KEEP__ sentinel the neighbouring lockout-rules field had been using all along: an absent field returns before writing anything. The trigger and the action have fields in all three themes. And a test asserts the correspondence in both directions — every setting the controller saves has a control that posts it back — so the next setting added to one theme and forgotten in two fails the suite instead of silently resetting itself.