30 August 2026¶
5 changes:
- Twenty-five migrations dated 2020 that were written in 2026
- A full cache flush never removed a directory
deferredwritesmoves topramnos, 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.