29 August 2026¶
27 changes:
- Two invisible columns, and a row of chips that ran off the panel
- Reading a sent message back: what it carried, and what came of it
- Web push: a notification on a device whose browser is closed
- The coverage gate reported 100% on a change that added an untested package
- Sending one account a message, on the channels it can actually receive
- Mass messages: an audience worth choosing, and push that reaches people
- The line the inbox shows, dark mode, and what DNS says about your mail
- Three MCP tools for the questions that were being answered by failing
- A log viewer in the DevPanel, at the address that used to 400
- The mail log grows without limit, and deleting it is the wrong fix
- The drift check's own false alarms, and the panel that had never worked
- The email preview's findings were taking a third of the width from the message
- Push had five parts and shipped four of them
- Every browser restart minted a session, and none of the old ones ever ended
- Nothing knew what kinds of mail an application sends
- Choosing who a mass message goes to, and seeing who that is
- The soft prompt was rendered on every page and never shown
- Nothing recorded a sent push
- The DevPanel had a second log viewer because of one hard-coded URL
- The database tab could not answer a developer's question about a database
- An unread message was something you had to go looking for
- Two development query logs that grew until the process died
- The database tab, again: chunks, and the half of the admin screen it still lacked
- Twenty-five forked PHP processes, for a constant
- The database tab, third time: copied rather than rewritten
- An unsubscribe is two records, and the second had nowhere to go
- The coverage gate, and the two bugs it found
Two invisible columns, and a row of chips that ran off the panel¶
Datatable::addColumn()'s second argument is bVisible, and it goes straight into DataTables'
column configuration. Two columns on the emails screen had it false.
The new Opens column was one: added, tested, and invisible. The other is older — the actions
column, the view and resend icons, has been hidden since it was written. Nothing about the code
reads as "hidden"; the argument is a bare false in a row of them, and every other screen in the
framework declares an actions column as ('Actions', true, false, false, 'html').
Both are visible now, and a test asserts that every column declared on that screen is. It reads the literal arguments out of the source, because the failure is a boolean in the second position of a nine-argument call and there is nothing else to look at.
Also, in the DevPanel: .range-bar wraps. It was written for a timespan selector — four fixed
chips — and is reused for the cache's namespace filter, which has one chip per namespace the
installation happens to have. On a real one that is twenty, several named after a table, and the
row ran off the side of the panel: the chips past the edge were unreachable and the page scrolled
sideways under everything else.
Reading a sent message back: what it carried, and what came of it¶
The mails table stores the rendered HTML, which means a sent message can be read back. The
preview screen showed four fields and an iframe; it now answers the question somebody actually
has when they open it — what happened to this email.
Pramnos\Email\MessageReport derives every finding from the stored body rather than from the
sending code, and that distinction is the point: a template that lost its unsubscribe link and one
that kept it are identical from the caller's side.
- Tracking. Clicks, opens and prefetches, counted apart. Whether the body actually carries a pixel, and how many links were wrapped.
- Gmail actions. The
ld+jsonblocks read back as Gmail would read them: the action type, its name, and the URL it points at. A block that is not valid JSON is reported — Gmail ignores it silently, which is precisely why this screen must not. - Links, and where they really go. A wrapped link's destination lives inside a signed token, so the address in the markup is not the address the reader reaches. Unwrapping it here is the only way to answer "where does this button actually go" — the question somebody has when a campaign points at the wrong page. A wrapped link whose token no longer verifies is marked broken: the reader gets the front page instead of the offer, and nothing else would ever say so.
- The plain-text half, rendered as a text-only client shows it.
Two findings are ones nothing else could surface, because they are disagreements between the body and the database:
- a tracked message whose body has no pixel — the row exists, the numbers stay at zero for ever, and it reads as "nobody opened it";
- a pixel with no row behind it — a remote image in somebody's mail that records nothing at all.
The layout, after the first attempt¶
Everything above went into one column and pushed the message off the screen: to read the mail you had come to read, you scrolled past four cards of analysis. The message gets the width now, and the findings sit beside it on a wide screen and after it on a narrow one — which is the order they matter in. The link list and the plain-text part are folded away, because they are long and are consulted rather than read.
Web push: a notification on a device whose browser is closed¶
The framework could reach somebody by mail, by database notification, and — while their
tab was open — over SSE or a WebSocket. There was no way to reach a person who was not
there. via() now accepts 'push', and a notification that implements toPush() arrives
on the phone in their pocket.
public function via(mixed $notifiable): array { return ['database', 'push']; }
public function toPush(mixed $notifiable): array
{
return ['title' => 'Νέα σύνδεση', 'body' => '…', 'url' => sURL . 'account/sessions', 'tag' => 'signin'];
}
Setup is push:vapid-generate once, migrate, and a service worker — the scaffolded stub
now carries the push, notificationclick and pushsubscriptionchange handlers.
The server holds no connection to anybody, which is worth stating because "push" suggests otherwise. The browser keeps one connection to its vendor's service, shared across every site it has subscribed to; sending is one ordinary HTTPS request per subscription, in parallel, and then nothing. Idle cost is genuinely zero, so this is the opposite trade from the realtime transports beside it rather than a replacement for them: use those for "this page should update now", and this for "tell this person even though they are not here".
RFC 8291's payload encryption is suggested rather than required. A framework that pulled
a push library into every application's vendor/ would impose it on every project that
sends no notifications; without minishlink/web-push the channel logs once and does
nothing, which is a better failure than a message that silently never arrives.
The distinction the whole thing turns on¶
A push service answers 404/410 when a subscription is dead — uninstalled, cleared,
revoked — and 429/5xx when it is busy. Confusing those is how this feature breaks in
either direction. Retrying a 410 fills the table with dead endpoints, each of which costs a
full HTTPS round trip on every future send, for ever. Deleting on a 429 silently
unsubscribes live users at exactly the moment the service is under load — and nobody reports
that one, because it looks like nothing happening.
So Subscriptions::recordResult() is a class rather than three lines in the channel, and a
failure with no response at all — a DNS failure, a timeout — is neither: it counts as one
bad attempt, ten of which retire a subscription that has never once worked. A single success
clears the count.
Two things that are only bugs later¶
openssl_pkey_get_details() returns the EC coordinates as big-endian integers with leading
zeroes stripped, so roughly one generated key in 256 is 31 bytes rather than 32. Unpadded,
that is a 64-byte public key which is occasionally 63 — a key pair that works, until one
installation's does not, and rotating it to fix it invalidates every subscription it had.
The coordinates are padded, and the test generates fifty pairs rather than one.
And browsers rotate a subscription's keys without asking, through a
pushsubscriptionchange event the page may never be open to see. A worker that does not
re-subscribe leaves every push returning 410: the user stops receiving notifications and has
no way to find out. The stub handles it.
The subscription table is unique on (userid, endpoint_hash), because subscribe() resolves
instantly once permission is granted — so a page that calls it on load calls it on every
load, and stored naively that is a row per page view and the same laptop notified forty
times.
Full setup, the interaction a notification can offer, and what Safari does not do: the new Web Push Guide.
The coverage gate reported 100% on a change that added an untested package¶
coverage --since HEAD intersects the clover report with the git diff, and a file the
report says nothing about was skipped. That is right for a guide, a stub, a migration or a
test — nothing outside the coverage whitelist is in clover, and counting those as uncovered
would fail every honest change.
It is exactly wrong for a new class under src/. Such a file is absent for the opposite
reason: no test ever loaded it, so PHPUnit never saw a line. Skipped, it contributed nothing
to either side of the ratio — so a change consisting entirely of new untested classes came
back "Every executable line you changed is covered".
Which is the single worst answer a coverage gate can give, and it gave it on a change that added a whole package.
Files under a directory the report measures — read from the report itself, not assumed to be
src/, because the whitelist lives in phpunit.xml — are now listed under unmeasured and
named in the verdict. Everything outside those roots is still skipped in silence, because a
warning that fires on every markdown file is a warning nobody reads.
Sending one account a message, on the channels it can actually receive¶
The Send screen on an account had one channel and four fields. It now offers email, an in-app notification and push, ticked independently, with every mail option the infrastructure has beside them: a wrapper, an unsubscribe list, open/click tracking, a Gmail action, and a link for the channels that can use one.
Those options are on this screen because this is where they get tried. A wrapper nobody has ever rendered and a Gmail action nobody has ever seen arrive are both things you find out about from a real message.
A channel this account cannot receive is disabled with its reason, not hidden and not silently dropped. No usable address, no VAPID pair, no browser subscribed — each says which, because an operator who presses Send and is told «sent» is entitled to believe it, and a channel that delivers nothing is invisible from the outside: nothing errors, the message simply never arrives. The two push reasons are also two different people's problems — «no browser has subscribed» is the account's, «this installation has no key pair» is the installation's — so the screen says which rather than «could not send».
A form that posts no channel field at all still sends the mail. An application that published the previous view posts exactly that, and read as «chose nothing» every one of them would have stopped working on update, with an error about channels nobody had ever seen a field for.
Notification\Message¶
Every other notification is a class per event — InvoicePaid, NewSignIn — which is right
when the event is known in advance. This is for when it is not: an operator writing a
sentence, an administration screen, a test send. No event to name, so no class to write.
$user->notify(
(new Message('Your export is ready', '<p>It is on your downloads page.</p>'))
->to('mail', 'database', 'push')
->link(sURL . 'account/downloads')
);
Each channel gets a shape it can use rather than the same string three times. The push body is stripped of markup and flattened to one line — a push is two lines on a lock screen, and handed HTML it shows the tags.
The mail options are declarations now¶
MailChannel already read unsubscribeList() off a notification. It now also reads
mailTemplate(), trackingRequested() and mailStructuredData(), so a wrapper, tracking and
a Gmail action are reachable from any notification rather than only from code that abandons
notify() and builds an Email by hand — which is what everybody did instead.
A notification that declares none of them is transactional and gets none of it, and that default is the important half: a password reset must arrive for somebody who unsubscribed from everything, an unsubscribe link on it teaches people the link does nothing, and a pixel on it tracks somebody who never agreed to be tracked.
'' and null are kept apart for the wrapper throughout — «no wrapper for this one» and «the
installation's default» are different answers, and conflated they leave an installation that
wraps everything unable to send a single bare body.
Mass messages: an audience worth choosing, and push that reaches people¶
The audience was three criteria — a usertype floor, validated, active — and the compose screen's count was built from them. It now takes a usertype band, the account's language, whether the account holds a second factor, when it last signed in, and which unsubscribe list to exclude.
Each of those is a message somebody wanted to send and could not. A ceiling, because with a
floor alone «everybody below staff» can only be written as «everybody», which then also
reaches the operators. Language, because a message written in Greek and sent to everyone also
reaches the people who set their account to English, and one message per language is the only
honest way to do it. Last sign-in, because «you have not been here since March» is a real
thing to say — and an account that never signed in at all has lastlogin = 0, so it is in
that audience, which is the correct answer to that question.
Excluding opt-outs changes the count, not the send. They were already skipped at delivery.
But the compose screen's count is the number that decides whether a send happens, and a count
that promises nine hundred people who unsubscribed decides it in the wrong direction. An
opt-out from all counts for every list: somebody who pressed «stop sending me anything» is
not asking to stay on the announcements list.
The second-factor filter fails closed. On an installation without authserver there is no
table to read, and «accounts holding a second factor» has to answer nobody rather than
everybody — the latter is a message about account security sent precisely to the people who
have none of it.
Push, which now exists¶
TYPE_PUSH delivered nothing and reported every recipient as failed, because there was no
transport. There is one now, so it delivers.
What did not change is the honesty of the report. An account with no subscribed browser is
a failure, not a skip — it is the ordinary case, most accounts have never granted
permission, and counting those as delivered would leave an operator reading «4,812 delivered»
about a message that reached forty people. And an installation with no VAPID pair or no
encryption library is checked before the send rather than left to the channel: the channel
logs and returns, sendNow() tells its caller nothing, so every recipient of a message that
was never encrypted would otherwise have been recorded as delivered.
The send options¶
A campaign can now carry a wrapper, an unsubscribe list, open/click tracking and a Gmail action — the same options the single-account screen offers, and they matter more here: a wrapper wrong on one message is a mistake, and wrong on forty thousand is the send. Tracking gets its own id per recipient, because one id for the campaign counts the first open out of forty thousand people and nothing after it.
They are stored under options inside the request JSON that already holds the criteria —
same decision, same audit record, and a column per option would be a migration every time
somebody adds one. A row written before they existed has no options key and reads as none.
One small trap closed on the way: the date fields are read as ISO only. strtotime() takes a
slash-separated date as American month-first, so 03/04/2026 is March on a screen that said
April — and the audience that selects is a different set of people, silently.
The line the inbox shows, dark mode, and what DNS says about your mail¶
Four things about a message that live outside its body, and were all decided by accident.
The preheader¶
Every mailbox list prints a message's first readable text beside the subject. On a wrapped
message that is whatever the wrapper opens with — a logo's alt, «view this in your browser»,
the first cell of a layout table. So the second most prominent line in the inbox, the one that
decides whether the mail is opened at all, was chosen by nobody.
Left unset it is derived from the body's own opening, cut to 100 characters and flattened
to one line, because «no preheader» is not a neutral state. The wrapper hides it three ways —
display:none, mso-hide:all for Outlook, a 1px transparent colour for the rest — and pads it
with zero-width non-joiners so the client does not follow it with the next thing it finds,
which is how «Your code is 481920 View this in your browser Unsubscribe» reaches somebody's
inbox. PlainText drops display:none, so it does not open the text part either.
Dark mode¶
Without color-scheme and supported-color-schemes, Apple Mail and Outlook invert the colours
themselves — per element, unaware of images — so a dark logo on the white card it was drawn for
ends up black on near-black. Declared, the client stops guessing and a
prefers-color-scheme block decides.
Every colour that block overrides is also inline: Gmail strips <style> from a forwarded
message and several clients drop it outright, so the block is an improvement where it survives
and never the thing keeping the message readable.
Accessibility¶
role="presentation" on every layout table, or a screen reader announces «table, two columns,
row one of three» about a message with no table in it. lang on <html>, from the language the
message was composed in — which is the recipient's, since Notifier and the mass dispatcher
both switch before composing; an empty lang is worse than a wrong one, because the reader
falls back to its own setting silently and announces Greek as if it were English. And 16px body
text, because mail is read on phones.
mail:dns-check¶
The part of deliverability that is not in the message at all. SPF, DKIM, DMARC and BIMI are records on a domain: this framework can compose a perfect email, set every header a provider asks for, and still have it filed as spam — with nothing in any log. The only symptom is mail quietly not arriving, reported months later as «I never got the password reset».
The command exits non-zero when the domain misses the bulk-sender bar, so it can sit in a deploy check — this is exactly the kind of thing that is correct on the day it is set up and wrong two domain transfers later.
It reports two states a «found / not found» check calls success. Two SPF records is a
PermError under RFC 7208: a receiver gets no result at all, so two records authenticate strictly
less than one — and it is a common state, because each was added by a different person for a
different service. And DMARC at p=none, which every tool reports as «DMARC found» and
which enforces nothing, while silently making BIMI impossible.
Three judgements are deliberate, because a check that cries wolf is one nobody reads: an
unchecked DKIM is not a failed DKIM (the selector belongs to whatever signs the mail, often a
relay); p=none clears Gmail's bar, so the verdict passes and the finding stays visible beside
it; and BIMI is not deliverability — it is a logo, a Verified Mark Certificate is bought and
needs a registered trademark, and an installation without one is not misconfigured.
The first run of it against a real domain found a bug in itself: ~ is a valid SPF qualifier
and was the pattern's delimiter, so the expression ended inside its own character class and
every record was reported as missing its all mechanism. A confidently wrong answer about the
one line somebody would then have gone and edited.
Three MCP tools for the questions that were being answered by failing¶
status — is the database up, are there migrations to run, is anything stuck in the
queue, when did something last go wrong. Four questions, four separate lookups, and therefore
usually none: a container that is not running is discovered by a failure, and a pending
migration by a column that does not exist.
The verdict is the product. Five sections of JSON is what somebody skims past; one line is what gets read — and an unreachable database is the whole answer rather than one finding among five, because every other section is unanswerable without it. Pending migrations are named rather than counted («3 pending» is a number; the names say whether they are this afternoon's work or somebody else's from a branch), and the last error carries its request id, which is the argument to the next tool. It takes no arguments and changes nothing: a tool called reflexively at the start of a session must not be able to start, migrate, clear or retry anything.
schema-drift — list-tables reads the live database and migration-status reads the
migrations. The question that matters is neither: does a migration create this table, and has
it run here? Three findings, three different problems — a table nothing creates (a fresh
installation will not have it, and the deploy that discovers that is somebody else's), a
migration that ran without leaving its table (every future run considers it done), and one that
simply has not run yet.
It reads raw SQL as well as the schema builder, and takes the hasTable() guard above an
interpolated CREATE TABLE {$t} as the table's name — several migrations have to write raw SQL
for a hypertable or a schema-qualified table, and reading only createTable() reports every one
of them as unmanaged, which is the loudest possible false alarm about the most carefully written
migrations in the project. Names are compared normalised, because the same table is spelled four
ways: #PREFIX#usersettings and pf_usersettings, authserver.permissions and
authserver_permissions. What is not flattened is the schema itself — a schema-qualified
table and a bare legacy one of the same name are two different tables, and treating them as one
hides the exact bug this was written for.
The migrations are read, never executed. A tool that ran one to find out what it creates would be a tool that migrates the database by being asked a question.
request-debug — what a request that died actually did. The debug toolbar answers this for
a response somebody is looking at; a request that failed carried almost nothing back. Listing
is the default, because the id is the part you do not have: it exists only after somebody has
read an error page and copied it out.
One bug the tests found in all three¶
The log writes d/m/Y H:i:s, and both new readers compared those as strings. 01/09/2026
sorts before 29/08/2026 alphabetically — so «the most recent error» is the oldest one for the
first days of every month, and right again by the tenth. Invisible on the afternoon it is
written, and back twelve times a year. Logger::timestampOf() parses the format now, and both
readers compare numbers.
And two smaller things¶
The framework-docs index is one line per page by default — name, and the first task it
covers. It was every use case of every page, about 27KB, and the measurable effect was that
grepping docs/ won instead: one line, and you know what comes back. {"detail": "full"} is
still there. 4.8KB now, and an index that fits in a glance is one that gets asked reflexively.
And every markdown file in the project's own docs/ is a resource, discovered rather than
listed. A project's notes — a request log, a decisions file — are exactly the documents somebody
wants in context from the first message, and they were never going to be named in the framework.
A log viewer in the DevPanel, at the address that used to 400¶
/devpanel/logs — the last lines of every log file, newest first, with a level floor, a
substring search and a file selector. Every filter is a query parameter, so a useful view is a
URL somebody can paste into a message.
The administration area already has a log screen: charts, a datatable, its own controller. It is the wrong thing to reach for while developing — behind an admin session, styled like the application, and what a developer wants is the last fifty lines and a way to grep them.
The address is the one the debug toolbar already used. It always passes ?request=<id> and
wants JSON; a person never does and used to get a 400 about a parameter they had no way to
know existed, on the one screen they would look for when something is in the log. An id now
means the toolbar and no id means a viewer, and nothing the toolbar sends changes shape.
Above the lines, the requests that failed — grouped by request id, from the same files. Short on purpose and empty on a server nobody is debugging: lines carry an id only while the debug toolbar is active for that visitor, because everybody else is logging into the same seconds and their lines are not a developer's to read.
Three details that matter more than they look. The tail, not the file: a log is hundreds of
megabytes on a server that has been up a while, and the lines being looked for were written a
minute ago. Ordered by parsed time, not string — the log writes d/m/Y H:i:s, and 01/09
sorts before 29/08, so a string sort puts the oldest lines at the top for the first days of
every month and is right again by the tenth, which is the shape of a «the log viewer is broken»
report nobody can reproduce. And a file name from the URL is a filter, never a path:
compared against the names actually on disk rather than joined to the log directory, so there is
nothing to get subtly wrong about how many .. a path can contain.
The mail log grows without limit, and deleting it is the wrong fix¶
mails is the table that grows without limit in every installation, and it grows for a reason
that is easy to miss: it stores the rendered body. A password-reset mail is maybe two
hundred bytes of facts — when, to whom, which module, did it send — wrapped around forty
kilobytes of HTML. At a thousand messages a day that is fifteen gigabytes a year of markup
nobody will read, and about eighty megabytes of the answers people actually ask.
So the policy is two stages, not one:
Stripping empties the body and keeps the row — and with it every question an operator asks months later. Deleting removes the row eventually, because an audit log with no horizon is not a policy. Deleting alone is the version people write, and it is the wrong shape: it throws away the cheap thing to save the expensive one. Stripping alone is also wrong — it leaves a table growing at eighty megabytes a year for ever. What stripping costs is the message report: nothing can be read back out of a message whose body is gone.
mail:prune is a dry run by default, which is the opposite of most commands here and
deliberate: this one deletes, the amount depends on two numbers somebody just typed, and the
difference between 90d and 90 is three months of an audit trail. A duration that does not
parse is no policy rather than a small number — a typo that meant «everything» would delete
a mail log on a scheduled run and nothing would ever say why. With nothing configured it reports
the table's size, explains the two stages, and assumes nothing: a default here would apply
somebody's guess to an audit trail on the first run of a command they were only exploring.
Scheduled daily at 04:10, where it does nothing until a policy exists.
Two smaller decisions. Deleting runs before stripping — the other order strips a body and then deletes the row it belonged to, having written every one of those rows twice. And the sweep works in batches, because a neglected table is millions of rows and one statement over all of them holds a lock long enough to make the maintenance the outage.
recipients_after covers massmessagerecipients, the other unbounded table: one row per
recipient per campaign, whose only remaining purpose once the campaign is finished is the count
on its page — and the count is on the campaign row.
The first run of the integration tests found the bug in it: getAffectedRows() is on Result,
not on Database, so every pass reported zero rows touched and the whole policy looked like a
no-op that had run successfully.
The drift check's own false alarms, and the panel that had never worked¶
schema-drift was run against a real installation on the day it was written, and most of what
it found was wrong about itself. Fixing that is the work, because a check with one false alarm
at the top of its report is a check nobody reads twice.
- Views are objects a migration creates. The live side reads
information_schema.tables, which lists views; the declared side matched onlyCREATE TABLE. Every view in the project came back as a live object nothing creates — twenty-two of them, on a report whose whole value is that its lists are short. - A migration can now declare itself conditional.
pramnos.framework_policiesexists on MySQL and plain PostgreSQL and must not exist on TimescaleDB, which manages its own policies: the migration runs, records itself applied, and creates nothing — correctly. From the outside that is indistinguishable from a table somebody dropped by hand, which is the loudest finding this tool has.public bool $conditional = true;, declared and not detected — «does thisreturndepend on the engine» is not a question to answer by pattern-matching somebody's source. - It says what it could not read. A migration naming its table with a constant or a setting
—
createTable(DeferredWriteQueue::TABLE, …)— cannot be read without running it, and the table it creates was appearing under «no migration creates this». Those are listed apart now, and the unmanaged note points at them. - And
quoteTable()is read like the other name-supplying calls, which is the only literal spelling of a table in a migration that then interpolates it into raw SQL.
What it found that was real¶
The user screen's token-actions panel had never worked. tokenactions has no userid — it
has tokenid, and the account is on the token — and no actiondate either; it is servertime.
Both queries could only fail, on every user screen, since the day the panel was written.
The failure was invisible in the way these always are: the per-panel guard catches, the panel renders empty, and an empty panel is exactly what an account with no API tokens is supposed to look like. There was even a test — it asserted the key was present, which it always was.
Joined through usertokens now, ordered by the column that exists, and the test inserts a token
and two actions and asserts the panel finds them. Which is the assertion that was missing: «a
panel that is silently absent is indistinguishable from a panel that is empty» was in that
test's own docblock.
The email preview's findings were taking a third of the width from the message¶
The message and the findings were side by side, and the sidebar took about a third of the page. That is a permanent cost, and the thing it was taken from is the one element on the screen with a designed width: an email is laid out for around 600 pixels and there is nothing useful to do with less. So a squeezed message sat next to a panel that, for almost every message, reads «Not tracked.»
Tabs instead. The message gets the page; each finding gets the page when it is asked for. That is the honest reading of how they are used — somebody opens this screen to see the mail, and goes to Tracking when they have a question about tracking.
The labels carry the answer, which is what stops a tab strip becoming five things to click through: «Tracking · off», «Gmail actions · none», «Links · 4». For most messages the strip itself is the report, and no tab needs opening at all.
Radio inputs rather than script — daisyUI's own tab pattern. The screen is inside the administration area under a CSP, and a tab strip is not worth a nonce.
The header card was four short facts in four full-width rows, which pushed the message below the fold before it had lost any width at all. Two columns now, with the subject across the top and the status beside the date.
The links panel gains from this more than the rest: a wrapped link's real destination is a long URL, and wrapping it over four lines in a sidebar is what made that panel unreadable.
Push had five parts and shipped four of them¶
Web push landed with the server side complete — a key pair, a table, a channel, three
endpoints, a documented service worker — and nothing that asks. No page called
requestPermission(), no page called subscribe(), so an installation with all of it had no
subscriptions, for ever, and nothing anywhere said why.
Reported as «ΠΟΤΕ δε μου ζητήθηκε το permission». It is the third instance of one pattern this week: machinery that works and is never reached.
What was missing¶
The browser half. init now writes www/assets/js/push.js beside the worker: asks from a
click, subscribes, posts, and says why when it cannot — iOS needs the site on the home screen,
a plain-HTTP host needs a certificate, a denied permission needs the visitor's own browser
settings, and none of those is «unavailable».
A version constraint on the library. composer require minishlink/web-push with no
constraint, in a project whose minimum-stability is dev — which every project scaffolded
from this framework has — installs dev-master: whatever was pushed upstream this morning, into
the code that encrypts every notification. Composer is right to do it and says nothing about it.
It is ^11.0 now, in the command, in the guide and in the suggest.
A place to say yes. A control on the privacy screen, and a soft prompt on every signed-in page. Not the browser's dialogue — the button inside it opens that, from a click. A real prompt on page load is denied by most people and suppressed outright by Chrome for visitors who habitually deny one, so the single chance an application gets is spent before anybody has decided anything. The invitation appears only when it can lead somewhere, and answering it — either way — hides it: a soft prompt that returns next page load is a nag, and a nag is answered with the block button, which is the one answer this feature cannot recover from.
A worker that is actually listening. Every project scaffolded before push has a sw.js
without the three handlers — registered, caching, and discarding every notification.
Push\ServiceWorker reads it and reports, from push:vapid-generate and from the status MCP
tool. The framework does not rewrite an application's files, so it reads them and says what is
missing.
showNotification() rejects. Permission can be revoked after a browser subscribes; the
subscription stays valid as far as the push service is concerned, so the server pays for a
delivery that can never be shown, for ever. Now caught, and on NotAllowedError only the server
is told to forget the subscription — a transient failure must not unsubscribe somebody who had a
bad moment. Uncaught, it was an unhandled rejection in somebody else's console, which is where
it was first seen.
push:setup¶
Five numbered steps in a guide are five chances to stop after four:
ok Migration
ok VAPID key pair
todo Encryption library — minishlink/web-push is not installed, so nothing can be encrypted
ok Service worker
ok Browser script
It says what each absence costs rather than naming a file, does only what is missing, and is safe to run again. A step that fails stops the run: carrying on would report four done and leave the one that mattered. The service worker is appended to, never replaced — it is the application's file, it caches the application's assets, and taking that away to add three handlers is not a fix.
And init asks. Answering yes turns the service worker on whether it was asked for or not:
offered as independent choices, push-without-a-worker is a combination somebody can pick and
would discover as silence.
On the user screen¶
A Push devices panel: which browsers this account subscribed, when each was last reached, and how many failures it has. It answers «why did they not get the notification» — almost always because nothing is subscribed — before anybody has to ask. The endpoint is neither shown nor read: whoever holds it can push to that browser, so it is a credential.
And the token-actions panel beside it, which had been rendering an em dash and a blank cell for
every row: the view read actiondate and action, and that table has servertime, method
and a urlid into the URL registry. It shows the request now — method, endpoint, status.
Every browser restart minted a session, and none of the old ones ever ended¶
Reported as «νομίζω ότι ακόμα δημιουργείς νέα token συνέχεια, ακόμα και αν ο χρήστης δεν κάνει logout→login, μόνο αν απλώς ξανα ανοίξει τον browser μετά από ώρες» — with a screen full of Active sessions to look at.
A new token per login is correct and intended. The old one not ending is not: a web_session
token is a bearer credential, and loadByToken() accepts the raw value regardless of any
session. One that has been superseded and stays valid keeps working for thirty days from a log,
a backup, or an old client.
createWebSessionToken() has retired its predecessors since 20 August, matched through
deviceinfo — and it had never once matched anything.
Two bugs, in the same column¶
Token::addAction() overwrote deviceinfo on every request. With
Helpers::getBrowser($_SERVER['HTTP_USER_AGENT']), whose output is
{"userAgent": …, "browser": …} — a different shape from the {"device": …, "label": …,
"ip": …} written when the token was issued, with no device key in it at all. So a token
carried a matchable fingerprint exactly until its first request, and never again.
The column therefore held two shapes, and which one a row had depended on whether it had ever been used. Both were present in a live table.
It also destroyed the evidence it looked like it was collecting. A token used from a browser it was not issued to had its record rewritten to say the new browser — so nothing could tell that anything had changed, which is the first thing anybody investigating a stolen token looks at. It is written at creation now, and left alone.
And the match was on the whole stored value. Which includes the address the token was
issued from. currentDeviceInfo() says, in its own docblock, that the address is "deliberately
not used to decide anything: consumer addresses are dynamic, and comparing them is how a
security signal becomes noise" — and then the retirement decided on it. A router reboot between
two sessions, a move to mobile data, any of the ordinary ways an address changes, and the old
token was left alive. It matches the fingerprint now, decoded, and nothing else.
Both halves have a test that fails without the fix — the same-browser case and the changed-address case — and the phone-and-laptop case still keeps both, which is the whole point of having more than one session.
Rows already written with the overwritten shape cannot be matched retroactively; they expire on their own thirty-day schedule.
Nothing knew what kinds of mail an application sends¶
Asked as «μήπως οι εφαρμογές χρειάζονται ένα registry του τι είδους email στέλνουν; για να λειτουργούν όλα τα νέα features;» — and the answer was yes, because four features were each working around its absence.
A kind of mail — «password reset», «weekly digest», «sign-in alert» — is what a person means
when they say they get too many emails from you. The unsubscribe list was a string typed at
each call site. The mass-send screen asked for one in a free-text box, where a typo invented a
list nothing suppresses against. The audit log's module column was whatever the sender
happened to write, so it could not answer how many digests went out. And there was no way at
all to show somebody the mail they can turn off, because nothing knew what it was.
MailTypes::register(new MailType('digest', 'Weekly digest', 'Every Monday.', 'digest')), and
then $mail->type('digest').
The one row that had never been done¶
Four things have to agree for a message on a list: the List-Unsubscribe header, its one-click
companion, the visible link in the footer, and not sending it to somebody who already left.
offerUnsubscribe() did the first three. The fourth was nobody's, so a message went out with a
working unsubscribe link to the address that had used the previous one — the reader unsubscribes
twice and concludes the sender is lying, which is what the spam button is for.
A type()d send to an opted-out address returns false, says why in getLastError(), and
still writes the mails row. «We did not send this, and this is why» is what an audit log is
for; without the row, why did they not get it has no answer anywhere.
The list is what makes it optional¶
A type with a list can be turned off. A type without one is transactional and cannot be — not a judgement about importance, but whether the message is a consequence of something the person just did. An unknown type name is treated as transactional rather than raising: the thing that would throw is a send, and a typo must not stop a password reset.
The framework registers its own four without being asked — newsignin, second-factor-code,
device-auth-link, security-change — so a plain installation has this rather than only one
that thought to declare its types. An application overrides any of them by registering the same
name.
And /unsubscribe is a preferences page¶
It used to be one button, and the button said none, ever. That is how somebody who would have kept one message of four ends up receiving none — and the sender reads it as a clean unsubscribe rather than as the failure it was.
It now lists every optional type with what it is and whether this address is receiving it, each
row a link carrying its own signed token for that address and that list. No session, and it
cannot be edited into changing somebody else's settings. a=in turns one back on, and is never
honoured for one-click POST: RFC 8058 says that endpoint unsubscribes, and a provider that
found a parameter reversing it would be right to stop trusting it.
An application that registers nothing keeps working exactly as before.
Choosing who a mass message goes to, and seeing who that is¶
Reported with a screenshot: «εδώ το ux είναι πολύ κακό. Δεν θα έπρεπε να έχω dropdowns ή multi selects; Επίσης, μπορώ να δω τη λίστα που διαμορφώνει ΠΡΙΝ την τελική αποστολή; Να εξαιρέσω ή να συμπεριλάβω user id; Αν θέλω να στείλω σε 1 χρήστη ή συγκεκριμένους; Μπορώ να έχω φίλτρο με user groups και οργανισμούς;»
Every one of those was a no.
Seeing the audience before sending it¶
Preview this audience posts the same form to a new action, which resolves the criteria and renders the form again with the answer on it: how many accounts, and the first twenty-five of them with id, username, address, usertype, language and last sign-in. Nothing is written and nothing is sent, so an operator can try a filter, look at it, and change it.
That loop is the whole point. Until now the only way to find out what a filter meant was the recipient rows of a message that had already gone out — and a send to the wrong band of accounts is not something anybody can take back. It is a form post rather than a fetch, so it works identically in the three themes and with no JavaScript.
The sample says how many it is not showing. A list that silently stopped at twenty-five would read as an audience of twenty-five.
Groups, organizations, and naming accounts outright¶
Multi-selects for the groups and organizations this installation actually has — and the picker is not rendered at all when there are none, rather than a disabled empty box above a sentence explaining that it does nothing.
Two textareas for ids: only these accounts, and except these. only_ids is the
commonest thing anybody wants from this screen and the one it could not do — the band, the
language and the second-factor filters are for describing a group you cannot enumerate, and a
list you can enumerate had no field.
Ids are read however somebody has them. From a spreadsheet they arrive newline-separated, from a chat message comma-separated, and from a colleague with spaces between them; all three are the same intention, and refusing two of them is a screen telling somebody their list is wrong when it is the screen that is.
Naming an account does not override the other filters. Somebody pasting a list has not checked which of those accounts is inactive, unvalidated or unsubscribed, and treating a paste as an override of every check on the page is not what they meant. The preview shows which ones dropped out.
And a filter that matches nobody is an empty audience, not everybody. A group filter falling back to "no filter" is how a message meant for eleven volunteers reaches every account on the installation, and the operator finds out from the replies.
The soft prompt was rendered on every page and never shown¶
«Επίσης ακόμα δεν ξέρω που ενεργοποιώ τα push» — after the soft prompt was added, on an installation that had it in its footer.
Two causes, and the second is the interesting one.
A stale copy. www/assets/js/push.js is generated boilerplate, so a project has whatever
version was current when push:setup last ran. This one predated the invitation: the footer
rendered data-push-invite hidden and nothing ever unhid it. push:setup reported the step as
done, because the file was there — and a file that exists is not a file that works. It now
checks the identifiers the current script has to handle, reports an older one as work to do, and
says «replacing» rather than «writing» when it rewrites it. Identifiers rather than a version
number, because a version number is a thing to forget to bump.
One button per page. The script used querySelector, and the settings screen has the
control twice by design — the invitation in the footer of every signed-in page, and a permanent
switch on the privacy screen. Only the first in the document was wired, so on that page the
invitation's button did nothing when pressed, which is worse than not offering it. Every
matching control is wired now.
Nothing recorded a sent push¶
«Επίσης, τα push που στάλθηκαν πού τα βλέπω;» — nowhere.
/admin/Emails has answered what was sent, when, to whom, and what came of it for email since
the messaging feature shipped. Push had no equivalent. pushsubscriptions records when a browser
was last reached successfully and how many failures it has since, which is a fact about the
browser rather than about a message; the mass-send path writes massmessagerecipients, which
covers one send path out of two. Everything a notify() sent — the ordinary path, and the one
every application uses — left no trace at all. The only way to find out whether a notification
had gone out was to ask the person it was for.
pushlog, Pramnos\Push\Log, and /admin/PushLog beside the email history.
The rows worth having are the refusals¶
One row per attempt against one subscription, because that is where the answers differ: an account with three browsers gets three, and «delivered, delivered, 410» is the shape of the real question.
And every reason a push does not go out writes a row. Nothing subscribed, no key pair, no
encryption library, a notification that produced no title — each was a silent return with, at
best, one line in a log file nobody reads until they already suspect the answer. They are the
answer: without them an installation with no VAPID pair and one where everything is arriving look
identical from every table, and the first is not a rare state, because the library is a composer
suggestion and the key pair is a step somebody can stop before.
The commonest of the four is «no browser on this account is subscribed», which is also the commonest true answer to why did they not get it.
The endpoint is not in it¶
Whoever holds it can push to that browser, so it is a credential, and a log is the last place to copy one to. The sha256 joins a row to its subscription and recognises the same browser twice, which is all this table is asked.
A hypertable, compressed and expired¶
Asked while it was being built — «τουλάχιστον το έκανες hypertable με σωστό compression +
retention (τα push δεν τα θέλουμε για πάντα)» — and no, it was a plain table with a DELETE
sweep. Which is the wrong shape: append-only, timestamped, queried by recency, never updated is
the shape TimescaleDB exists for, and a delete over a large table rewrites index pages and
leaves bloat only a VACUUM FULL reclaims.
Partitioned on sent in 7-day chunks, compressed after 7 days segmented by status, and
dropped after 90. status rather than userid for the segment: a handful of distinct values
and the column the useful query filters on, so a batch that cannot match is skipped without being
decompressed — userid would produce one segment per account and compress almost nothing.
Dropped, which an audit trail deliberately is not: a push is cheap to send so applications send many, and nobody needs to know which notification a browser acknowledged last spring.
sent is a timestamptz rather than a unix integer, because the two policies take interval
strings that only mean anything against a timestamp — and Log::sentAt() is the one reader that
knows what each driver hands back, so no view parses a date itself.
Reachable from both directions¶
A navigation item next to Emails, and a Recent pushes panel on the user's card linking to that account's own history — because «they say they did not get it» is asked about one person, on their screen, and a link that lands on every notification the installation ever sent is a link nobody follows twice.
Why a new table¶
Asked while it was being built, and the answer is in the migration rather than only here:
notifications is the in-app inbox and is written only when a notification asks for the
database channel; messages is the account's own mailbox, so logging into it would show
somebody every notification twice and «no browser on this account is subscribed» is not a
message to put in an inbox; pushsubscriptions is a credential store with one row per browser;
and massmessagerecipients needs a massmessages header, which would turn every sign-in alert
into a campaign.
The pairing is the framework's own, not a new pattern: the mass-message dispatcher writes the
inbox row to messages and the delivery record to massmessagerecipients. This is that
same pair for the push channel.
The DevPanel had a second log viewer because of one hard-coded URL¶
«γιατί τα logs στο devpanel δεν είναι τα ίδια με τον κανονικό controller; νόμιζα ότι μπορούμε να έχουμε το ίδιο.»
They could. The panel served its own viewer — a table with three filters — beside a
LogController that has had pagination, reverse order, follow, per-level filtering, statistics,
cross-file search, export, rotate and archive all along. Two implementations of reading a log,
in the same framework, one of them worse.
The reason was a single line. LogViewer built the address of its own raw endpoint from
adminUrl('logs'), so the component could be embedded in exactly one place: anywhere else, its
frame pointed at a screen behind an admin session and rendered a login page inside the panel.
Rather than make that a parameter, a smaller viewer was written.
renderViewer() takes a base URL now. The panel serves the same component from /devpanel,
with /devpanel/raw as its own copy of the endpoint the frame loads from — guarded by a signed
debug grant, which is the whole reason to read a log from there rather than from the
administration area. Ninety-seven lines added, a hundred and thirty-three deleted, and the panel
gained everything the shared viewer already did.
What it keeps of its own is the part the administration screen has nothing like: the requests that failed, which exist only while the debug toolbar is tagging a visitor. What it does not reproduce — statistics, cross-file search, filter, export — is linked, with an honest note that those need an admin session. Writing a second copy of each is what produced the viewer this replaced.
The database tab could not answer a developer's question about a database¶
Asked for by name: «να έχει περισσότερα εργαλεία από το αντίστοιχο database status του διαχειριστικού (π.χ. active processes)».
It had fewer. The administration screen has statistics, the process list, table sizes,
replication, public views and TimescaleDB data. The panel had table sizes and hypertables — and
it read them with its own copy of the query, the third implementation of "how big is this
table" in the framework, written straight past the DatabaseInspector the administration screen
already used. Same lesson as the log viewer, on the tab beside it.
It now uses the shared inspector, and adds the three things neither screen had:
Active processes. What the database is doing right now, with the query text. active_sec is
the running query's own age and is null unless the backend is running one; idle_sec is how
long a pooled connection has been sitting there. As one number, an idle connection reads as a
query running for 194 minutes.
Indexes nothing uses, and tables read the hard way. The developer's question about a database, and the one no screen here asked. An index nothing scans costs a write on every insert and update and buys nothing; a table with ninety million rows read sequentially and no index scans is a query written before the data grew. Neither is visible from a list of table sizes, which is what every screen showed instead. Primary keys and unique constraints are excluded from "unused": they are not there to be scanned.
The slowest statements, by total time rather than by mean — a query taking two milliseconds
four million times is the one to fix, and it never appears in a list ordered by mean. And a
missing pg_stat_statements says so rather than rendering an empty table, because "not
installed" and "no slow queries" are different facts and one screen for both tells somebody
their database is fine when it has never been asked.
The other half of the request — moving the developer-only sections out of the administration screen — was left alone. It was asked as a «μήπως», the only clear candidate is the view- definition listing, and it is woven through three themes' worth of markup and modal JavaScript. Removing it is a visible change to a screen somebody uses, and not one to make on a maybe.
An unread message was something you had to go looking for¶
«στο front end πρέπει τα μηνύματα να φαίνονται όταν έχω αδιάβαστα. Ίσως κάτι σαν το notifications icon του fb.»
MessagesController::unreadCount() had existed since the inbox screen shipped, and it had
exactly one caller: the inbox itself — the one screen where the number is redundant, because you
are already looking at the messages. Anywhere else on the site there was nothing to say a message
had arrived. Which, for a message somebody sent you, is the whole problem.
NavItem takes a badge now, and the messaging item carries the count.
Why it is a closure¶
Navigation is registered once, at boot. A number resolved there is the count as it was when the process started — for an unread badge, always wrong and usually zero. The closure runs when a page renders.
badgeLabel() writes anything over ninety-nine as 99+: the difference between a hundred unread
and four hundred is not one anybody acts on, and a four-digit badge is wider than the label it
sits beside.
Four things it will not do¶
A badge is decoration on a screen that is about something else, and a navigation item that throws takes every page on the site with it. So: it is never asked for a signed-out visitor — the navigation renders for everybody, and a count for user 0 is a query against an account that does not exist, on every page, for every crawler. It is resolved once per account per request, because a header and a mobile menu are two renders of the same list. A closure that throws counts zero. And a negative answer counts zero, because «-1 unread» reads as a broken page rather than a broken count.
All three themes draw it, with an aria-label carrying the meaning — a number on its own is
announced as a number, and «Messages 3» tells a screen-reader user nothing about what the three
are.
Two development query logs that grew until the process died¶
FW-044 and FW-045, reported from another project: a 3,123-test suite dying at the 2,394th with exit 255 and no message, and a single test taking 235 seconds before the OOM killer reached it. Exit 255 with no message is what PHP's memory limit looks like from the outside.
Five unbounded stores, all of them only reachable in development or in a long-running process — which is why nobody browsing a website ever saw them.
Database. $_querieslog held the full SQL of every query for the life of the process and
was written to its file in the destructor, so a process that was killed wrote nothing at all:
the log was empty on exactly the run somebody needed it for. It is flushed every quarter of a
megabyte now. $_duplicateQueries was an array keyed by the whole SQL string — a suite
issuing fifty thousand distinct statements held fifty thousand of them as array keys, to answer
a question a 32-character hash answers just as well. And $_inMemoryQueryLog held every
statement with its timing for a debug toolbar that renders the last few dozen.
User. $usersCache holds a whole User object per account and $_usercache holds
(array) $this — every property, otherinfo included — and both live for the process. And
getUsers() built one fully-loaded user per row with no limit, loading each one twice:
new User($id) loads, because the constructor's whole else branch is return
$this->load($userid), and the explicit load() after it read the same two tables again. Every
list cost twice what it needed to.
All five are bounded, and each drops half at a time rather than one: trimming a single entry per
insert past the limit is O(n) per operation, which makes a cache slower the longer the process
runs — the opposite of what a bound is for. getUsers() takes a $limit, defaulting to
everything, because changing that silently would truncate a caller's list without telling it.
The database tab, again: chunks, and the half of the admin screen it still lacked¶
Two things wrong with yesterday's change, both reported from the screen.
It listed TimescaleDB chunks as tables. _hyper_7_15_chunk and forty like it, crowding out
mails and users. The tab's own query had filtered nspname = 'public'; the shared inspector
it now uses filtered only the catalogue schemas, so switching to the shared one introduced it —
and the administration screen, which had always used that inspector, had the same list all
along. Fixed in the inspector, so both screens stop showing them. A chunk is the extension's own
partitioning, named after nothing a person recognises, and listing them also double-counts
storage the hypertable already reports. They are counted in the TimescaleDB section instead.
It was still not a superset. «δεν έφερε τη λειτουργικότητα του admin» — the version, size, connections and cache-hit ratio; the replication status; the views. It has all of them now, plus the TimescaleDB jobs, which neither screen had: a hypertable whose compression policy has been failing for a week looks perfectly healthy from the hypertable list alone.
Twenty-five forked PHP processes, for a constant¶
DevPanelControllerTest ran #[RunTestsInSeparateProcesses], and the reason was honest: its
setUp() did define('DEVELOPMENT', true), a constant cannot be undefined, and without
isolation it decided the whole run was "developing" — permanently, for every test after it. Two
middleware tests had grown to depend on that and passed in a full run while failing alone.
Isolation fixed the leak by forking a PHP process per test. Twenty-five bootstraps for a constant.
Application::isDeveloperEnvironment() — which is what the panel actually asks — already
honours APP_DEBUG, and an environment variable can be unset. So the lock is opened with
one in setUp() and closed in tearDown(): the leak is gone for the right reason, and no
process is forked.
What that did not fix¶
Four cache tests in that class still cost about 2.4 seconds each, and the isolation was not why.
Measured and ruled out: Cache::getInstance(), getStats(), getAllItems(), getCategories(),
clear(), Settings::getSetting('cache') and renderCache() itself are each under 5ms
standalone, and the class's other twenty-one tests share the same setUp() at 0.05s or less.
Recorded rather than guessed at — the next person starts from that list instead of from zero.
The database tab, third time: copied rather than rewritten¶
«Γιατί σου είναι ΤΟΣΟ δύσκολο να το αντιγράψεις; είναι και τα 2 στο ίδιο codebase.» — a fair
question, and the answer is that I had been re-implementing /admin/dashboard/database section
by section from memory instead of reading it. Three rounds of "it still does not have X".
Read properly, it has: an overview strip, active processes, replication status, table sizes, public views, hypertables, continuous aggregates and scheduled jobs — in that order. The tab now has all of them, in that order, with the same columns and the same empty states.
And three things it adds, which is what the tab is for:
- A Kill button on each process.
pg_terminate_backend, notpg_cancel_backend: cancel asks a query to stop and a backend stuck in a lock wait ignores it, which is exactly the backend somebody is trying to end. It asks first, because the connection dies with the query. - A Copy button on a query and on a view definition.
- The job error history on the page rather than behind a modal — and rendered even when nothing has failed. Hidden until there is something to show, it is a section nobody can find, and "no job has failed" is an answer somebody came here for as often as the list is.
Client ClientRead is not a problem¶
Every idle PostgreSQL backend sits on it: the backend has finished and is waiting for the
application to send the next statement, which is what an idle pooled connection is for. Shown
as a red badge on every row it says the database is in trouble when nothing is wrong, and two of
those is all it takes for nobody to read the column again. Only Lock, LWLock, BufferPin
and IO are shown now, and only for a backend that is actually running something.
And the CSS¶
A Copy button with no rule rendered as the browser's own — white on white, in a dark panel. A
Kill button inherited .btn-danger, which is a page-level slab with a top margin: one per row
turned the process list into a column of pink blocks a third of the table wide. And a query cell
wrapped to four lines, so eight processes filled the screen and the columns that say which
backend scrolled out of reach.
The administration screen loses the view definitions¶
A listing of every view's SQL is a schema-shape question, which is a developer's; that screen is
an operator's. It is on the panel now, beside the index usage and the slow statements, which are
the same kind of question. DatabaseInspector::getPublicViews() is unchanged — only the
administration screen stopped asking.
The push tables move to the pramnos schema¶
public is the application's. pushsubscriptions and pushlog are the framework's own
bookkeeping — nobody writing an application queries them, and a \dt that lists them beside
users and mails is a \dt that takes longer to read. Both were created today and nothing
has been deployed from them, so the migrations are edited in place rather than followed by a
move.
SchemaBuilder::ensureSchema() comes with it: CREATE TABLE pramnos.x on PostgreSQL fails
outright when the schema is not there, and "somebody else's migration made it" holds until
something runs one feature's directory on its own — which every integration test that touches a
feature does.
And so do the email ones — with a second bug found on the way¶
Asked as «Τα email tracking κλπ, και αυτά εκεί δεν πάνε;», and the first answer was wrong: the
filenames say 2020_01_01_*, which reads as baseline. git log --diff-filter=A says
emailoptouts was added on 28 August and emailtracking on 29 August — new migrations
with a backdated name.
Which is a bug in itself, and pramnos-check has a rule for it: 2020_01_01 is the baseline
epoch, and an installation that predates the migration system sets migration_cutoff =
2020_01_02_000000 to skip all of it. A new migration with that prefix is silently never run
there, and nothing reports that it was skipped. Renamed to the dates they were actually written.
emailoptouts, emailtracking and emailtrackingclicks move to pramnos with them: nobody
writing an application queries an opt-out record or a tracking pixel — Unsubscribe and
Tracking do.
The genuinely old tables stay where they are. mails, messages and massmessages are read by
applications anyway, and deferredwrites would fit pramnos on the same reasoning but has been
in public since 12 August — moving a table that exists in a deployment is a data migration, not
a rename.
An unsubscribe is two records, and the second had nowhere to go¶
«τα email unsubscribe δεν θα μπορούσαν να είναι στο user_consents;» — as the lookup no, as
the record yes, and it was not being written anywhere.
emailoptouts is a suppression list: isOptedOut() runs before every optional send, so it has
to be an indexed existence check. It holds current state — opting back in deletes the row — it is
keyed by address, because somebody on a list often has no account at all, and nothing ages it
out, because a withdrawal that expired would start sending again.
authserver.user_consents is the opposite by design: append-only, one row per grant and per
withdrawal, with a legal basis, compressed after six months and dropped after seven years. Which
is exactly what a consent trail is, and exactly what a suppression lookup must not be.
So both. The event is written there too when the address belongs to an account and the auth
feature is present — giving that table its first writer, which is worth saying on its own: it had
been in the schema since the baseline with nothing putting anything in it.
Best-effort in every direction. The table belongs to a feature an installation may not have, an address may have no account, and either way a consent trail must not be the reason an unsubscribe fails — that is the one failure a mailbox provider counts against every future message.
The same pairing the framework already uses for a mass message: the inbox row in messages, the
delivery record in massmessagerecipients.
The coverage gate, and the two bugs it found¶
The batch closed at 95.4% on changed lines — 49 of 1,056 uncovered, all of them catch branches and PostgreSQL-only code the MySQL suite cannot reach. It started at 75.8%.
Two of the tests written to reach it failed on real code rather than on themselves, which is the only reason the number is worth chasing:
killProcess() reported a kill that had not happened. pg_terminate_backend() on a pid
nothing is using returns false and the statement succeeds — so a row-count check called it a
kill. That is the ordinary case, not an edge one: the process list was rendered a minute ago and
the backend has finished since, and the screen would have said it ended something already gone.
DevPanelControllerIntegrationTest had no #[CoversClass]. Twenty-odd tests exercising
DevPanelController, none of them counted — which is why the tab looked untested while being
covered, and why the number moved eight points from an attribute rather than from a test. The
same was true of the two new integration classes: a test for a controller, attributed to the
store it reads. Coverage attribution is a claim about what a test is for, and getting it wrong
hides work in both directions.