28 August 2026¶
66 changes:
- A sign-in is questioned when it looks wrong, not when the browser is new
- One TOTP code, one login
- A password change that cannot be the same password
- The bundled sign-in forms can price automation
- The mail an account was sent is on the account's screen
- Themed email: the column that had never been read
- An email is written in the recipient's language
- A datatable over an
authserver.*table read as empty on MySQL - Authorization is three layers, and the guide said two
- Two reported bugs: a date of zero, and a cropped PNG's black corners
- Something runs the second-factor cleanups
- The services screen says whether anything is listening to its buttons
- A message to many accounts, composed and sent from a screen
- Three test classes were spending their time emptying the cache
- Requiring a second factor, and requiring a real one
- The debug bar says where the second factor stands — and stops forgetting your tab
- Three dead ends on the administration screens
- A settings row no longer opens the debug toolbar
- Nothing on the settings screen opens the DevPanel any more
- A scaffolded application comes with something that runs its background work
/admin/Servicessays how to create the supervisor- New-sign-in alerts can be on unless turned off
- The DevPanel's Back button goes where you came from
Sessioncan write, not only read- One apostrophe no longer destroys a page's breadcrumb structured data
Html\Daterenders the time and the dropdowns it always claimed to accept- The queue worker runs under a supervisor
/admin/Servicescan see a supervisor in another container- An unsubscribe link, and the two headers Gmail actually reads
Addon::trigerAddon()refuses a nameless addon too- The default language is a list, not ten characters of free text
- Adminer, at
/adminer, behind the application's own gate /messages— the inbox those internal messages were going into- Any browser with JavaScript can solve the human check — and a test proves it
- A CSP-blocked redirect, and a script with two nonces
- Adminer signs itself in
Html\Datereads the properties it declares- An idle connection is not a query running for three hours
Html\Date's field is validated by the browser again- A date is written the way the language writes dates
- Two of the widest columns on the process list said the same thing four times
- Two empty boxes where the log charts should be
- The log dashboard's figures, asked for by something that is not a screen
- The components guide listed
Seoand then never mentioned it again mcp:servehad its own copy of the tool catalogue, and it was stale- The most frequent error in the log was the framework asking a question
- Every log entry was dated the moment you looked at it
mcp:serveis not something a person could debug- An MCP tab in the DevPanel: the schema as a form, the answer on the page
find-symbol: the question grep cannot answer- The DevPanel's MCP tab shipped with a JavaScript syntax error
route-listexecuted the views, and then said there were no routes- A link in the DevPanel is styled wherever it is
- Two more MCP tools: what the CLI can do, and what the theme is made of
- Three tests that were a copy of the tool catalogue
api-docsandfind-tests: the other two of the four- Two rules that could not be checked, and now can
changelog-add: the one tool that writes- The plain-text part of an email was the CSS, with the links removed
- Four headers that decide what happens to a message
- Gmail actions: a button in the message list, and the reason yours is not showing
- A ViewAction never needed a handler — the password-reset mail has one now
- One-click mail actions, and the handler a "this wasn't me" button needs
- The unsubscribe page was 181 KB, and 180 of them were the website
- A session count that was not a number, and four tables called
sessions - Email tracking that works, and only for mail somebody agreed to receive
A sign-in is questioned when it looks wrong, not when the browser is new¶
"A device this account has not used" was the only thing the new-device policy could ask about, and on a real user base it fires constantly: people buy phones, clear cookies and borrow laptops. A step-up attached to that is a tax everybody pays regularly, and the usual end of that story is the setting being turned off.
auth_newsignin_trigger chooses what qualifies — new_device (the default, and what the
feature did before) or suspicious, which asks SignInRisk and accepts only the signals
that are hard to explain innocently:
| Signal | What it means |
|---|---|
new_country |
the account has never signed in from this country |
impossible_travel |
a different country from the last sign-in, too soon to have travelled |
concurrent_elsewhere |
another session is live right now on an unrelated network |
after_failures |
this success came straight after a run of failed attempts |
application |
something the application's own listener flagged |
new_device is deliberately not in that list. It is the novelty signal, and treating it
as suspicion is exactly what makes a step-up fire on every new phone.
What it can honestly see is stated in the class, because the limits matter. There is no
IP-to-location database in this framework, so the country comes from Cloudflare's
CF-IPCountry — the header the session tracker already uses — or from a listener an
application registers on auth.signin_country. Two consequences: "impossible travel" is
measured at country granularity rather than in kilometres (Rome to Milan in ten minutes is
invisible; Rome to Jakarta is not), and "two places at once" falls back to comparing address
prefixes when no country is available, which catches a different network rather than a
different street. An application with real geolocation adds its own signal through
auth.signin_risk instead of having this class pretend.
Cloudflare's XX is treated as unknown rather than as a country: read as one, every
unresolvable address would look like the same consistent place — and then the first
resolvable one would look like impossible travel.
The country is now recorded in the login activity entry, because it has to be: an address
in an old row cannot be resolved later, so a country not written at sign-in is a country
lost. An account whose history predates that is not flagged — an empty history means
unknown, not nowhere, which is the same rule the device check already followed and the
reason switching a signal on does not question everybody at once.
Found while testing it: the two history readers looped with $result->MoveNext(), which
does not exist on this Result — the loop reads the first row for ever. That is a hang
rather than a wrong answer, and it was in code no test had reached yet.
One TOTP code, one login¶
auth.security.totp_replay_cache. The existing guard is a last_used timestamp on the
account, which stops a code being reused one request after another. It cannot stop two
requests inside the same 30-second window: both read the same timestamp, both conclude the
code is fresh, both are let in. A phished code replayed immediately, or a double-submitted
form on a slow connection, is exactly that shape.
Answering it needs a store both requests can see atomically, which Cache::increment()
already is — a count of 1 means this request claimed the code. The key is a hash of the
code, so a cache dump hands out no live factors, and it expires with the window it belongs
to plus the drift the verifier accepts.
Opt-in, and it fails open: with no counting cache the code is allowed through on the older guard rather than refused, because a login that fails because Redis is down is a larger failure than the window this closes.
A password change that cannot be the same password¶
auth.security.password_history, remembering N previous hashes in
authserver.password_history. Only useful where there is a reason to change — a suspected
leak, an operator reset — and in exactly that situation the first instinct is the password
the person already knows, which produces the appearance of a change and none of the effect.
Its own table, not usersettings or userdetails: both of those are rendered on the
administration screens, which is right for switches an operator must see and wrong for
password material. Nobody needs to look at these.
Compared with PasswordHash::verify() — the same call the login makes — so it is already
right about every scheme the framework can read, and there is no second comparison to get
wrong. Pruned on write, since the table only grows when somebody changes a password, so
there is nothing for an operator to remember to run. And it fails open: with no table,
nothing is refused.
The bundled sign-in forms can price automation¶
auth.security.human_check puts \Pramnos\Security\HumanCheck on the sign-in, registration
and password-reset actions — true for all three, or an array naming them.
The class was already here; nothing used it. It also had no client to speak of: the shipped
pf-humancheck.js exposed a solver and left every page to wire it up by hand, which is the
kind of "available" that means "unused". It now auto-wires any form carrying
data-pf-humancheck, solves in a worker while the visitor types, and holds submit until
the answer is ready rather than blocking the button.
The check sits immediately after the CSRF check, before the credentials are read, so a refused submission costs no password verification and no mail. Verification fails closed — no challenge means no submission. Minting fails open: a challenge that cannot be created leaves the page rendering, and the verification refuses the post anyway, which is the same answer from the other end.
Two things are worth knowing before switching it on. Proof-of-work prices automation, it
does not stop it: a passed check is not evidence a human was there. And a browser with no
Web Worker or no crypto.subtle cannot submit the form at all, which is why the switch is
per form — pricing registration is usually worth that, pricing sign-in is a decision about
who gets locked out.
The view side is one line — <?php echo humanCheckField($this->humanCheck ?? null); ?> —
which returns an empty string when the check is off for that form. It was a partial for an
afternoon, and that was wrong for a reason worth writing down: a partial lives in a view
directory, view directories are per-application, and the sign-in page is the one screen no
project inherits. So every project would have had to copy the framework's markup in to use
the feature, and then own that copy.
Three things had to be fixed before any of it could work at all, and the other two were the same mistake: a feature shipped with a default that forbids it.
HumanCheck signed with securitySalt, and invented a random key per instance when there
was none. Every challenge it minted was then refused by the request that tried to verify it
— every visitor told their answer was wrong, for ever, with nothing logged by the request
that failed. That reads as a broken feature, not an unconfigured one. It now generates a key
once and keeps it in humancheck_secret. Deliberately not in securitySalt: that value
salts stored passwords, and filling it in would change how every existing password verifies.
And worker-src 'self' refused the solver. pf-humancheck.js builds its worker from a Blob
so that adopting the check costs one script tag instead of two files, and a blob: URL is
not this origin — so the browser blocked the worker, the client submitted an empty solution,
and the server refused it. The default is now worker-src 'self' blob:, which gives up
nothing: creating a Blob URL means running script on the page, which script-src and the
nonce already govern. Third directive in that list to have blocked something the framework
itself ships, after media-src and worker-src 'none' before it.
The mail an account was sent is on the account's screen¶
An Emails received panel on users/view/{id}: the ten most recent, newest first, each
subject linking to the mail itself, with the total in the panel header.
It closes a question that had no answer on the screen where it gets asked. "I never got the code" is the most common support message an authentication server receives, and the mail log is indexed by address while the user screen is indexed by account — so answering it meant copying an address off one screen and searching another, and in practice it was answered with "it must have been sent".
Matched on the current address, since mails has no userid. That is also the limit worth
stating: mail sent to an address the account used before it was changed is not this account's
mail as far as this table is concerned.
And a latent break it exposed. Email::send() wrote the mail log, and the Emails screen
read it, through a bare mails — no #PREFIX#. On an installation with a table prefix that
is a table that does not exist, so every mail this framework sent went unrecorded and the
screen showed nothing. Nobody had noticed because the guard that catches this learns which
tables are prefixed from the code's own usage, and until a #PREFIX#mails existed there was
nothing to compare the bare ones against. Three call sites, fixed with the panel that found
them.
Themed email: the column that had never been read¶
mailtemplates.emailtemplate has been in the schema since 2020. The administration screen
renders a field for it, save() writes it, and nothing has ever read it — so the answer to
"does this framework support themed email" was "there is a column for it", which is the same
answer as no.
\Pramnos\Email\EmailTheme is the missing half. A wrapper is a named
{name}.html.php that receives the body as $content and returns the document that goes
out; Email::send() applies it, once, and the mails audit log records the same string the
mailer was handed. Email::setTemplate() names one for a single message, emailtheme names
one for all of them, and the test-send on /admin/MailTemplates now passes the template's
own — so what arrives in a test is what a recipient would get.
Three decisions worth keeping:
Off until named. emailtheme is empty on an existing installation and an empty name
wraps nothing. Every application already sending mail through this framework writes bodies
that are complete messages; wrapping them on an upgrade would nest one <html> inside
another, and nobody would have asked for that.
null, '' and a name are three different things — the installation's default, no
wrapper for this one, and this wrapper. Without the middle one there is no way to send a
single bare message from an installation that wraps everything, which is what a
machine-readable mail needs.
It fails open. A missing or raising wrapper logs and the message is sent unwrapped, because the code in it is what somebody is waiting for. That is what makes a typo in a settings field cost one unbranded email instead of every email.
Not per theme, deliberately: a theme is a stylesheet and mail cannot use one. HTML mail is
nested tables and inline attributes, because Outlook renders with Word's engine and Gmail
strips <style> from forwarded mail. The bundled default is written that way — copy it to
app/emails/ and edit it there, where it wins over the bundled one.
An email is written in the recipient's language¶
Every notification Notifier::sendNow() dispatches now renders in the notifiable's own
language — users.language on a User — and the framework's auth mail (the second-factor
code, the new-sign-in alert, the finish-signing-in link, the security-change notices, the
password-reset link) goes through the translator instead of carrying English literals.
The language of a request is the wrong answer for a mail, and it is the answer everything had been giving: the request belongs to whoever made it, which for a password reset issued from an English administration screen is not the person who reads the mail, and for a queue worker is nobody at all. An account whose every screen is Greek was told about its own new sign-in in English, and there is nothing in such a mail that looks like a bug.
In Notifier rather than in each notification, because every one of them has the same answer
and each would have got it right separately or not at all.
Language::using($language, $render) is the switch, and the reason it exists rather than two
load() calls: load() merges. addlang() is an array_merge, so loading Greek and
then loading English again leaves every Greek translation in place, and the next English
message comes out in Greek. It snapshots the catalogue and puts it back — on the way out of a
raised exception too — and ignores a language that is not installed, since the name comes
from a column somebody can write and load() builds a path from it.
And the cache that would have hidden it. Factory::getLanguage() kept a static
$instance of its own, filled on the first call and never revisited — a second answer to
"which Language object is the Language object". So Language::setInstance() could not do
what it promises: an application installing its own Language, or switching the active one,
changed the object Language::getInstance() returns while t() and l() went on
translating through the one the factory had cached. Not a crash — a page, or an email, in the
language nobody asked for. It now delegates, and Language::getInstance() is the only cache.
A datatable over an authserver.* table read as empty on MySQL¶
QueryBuilder::from() flattens authserver.foo to {prefix}authserver_foo, because on
MySQL schema.table is a cross-database reference and the framework wants a namespace. That
resolution was skipped whenever the string contained a space, on the reasoning that an
aliased expression is not something to rewrite.
Datasource::render() builds exactly that string — from($table . ' a'). So every datatable
over an authserver.* table asked MySQL for a database called authserver, and each of
those call sites catches the failure and answers an empty list. On a screen that is
indistinguishable from "this account has no history": the activity panel was empty on every
MySQL installation, and the endpoint's own test passed.
Now the table is resolved and the alias kept, for the two shapes that are certainly
name alias and name AS alias. Anything else — a join fragment, a function call — is left
alone, which is what the old guard was protecting.
And a correction. The commit that added those tests said the authserver.* writes were
"a PostgreSQL schema and cannot be reached from the MySQL fixture". That is wrong, and it is
wrong in a way that mattered: on MySQL those names resolve to ordinary tables in the current
database, so the fixture could always have created them. What it created instead was a
database named authserver with the tables inside it — a place nothing under test ever
looks. Every read returned empty, every read is guarded, and the assertions had been reduced
to which keys the result array had. A fixture in the wrong place is worse than no fixture:
no fixture fails.
Authorization is three layers, and the guide said two¶
The Authorization guide opened by describing authorization as "two halves" — gates and permission rows — and never mentioned usertype capabilities, which are the layer that answers first and the one every bundled admin screen is guarded with. A reader looking for "how do I restrict this" found the two layers that need a record to reason about and none of the one that does not.
It now names three, with the test for telling them apart: if the answer changes when a row
changes it is a permission; when somebody's usertype changes, a capability; and if it has
to look at the record — author, state, owner — a gate. Plus the thing most often mistaken for
the first: admin.min_usertype is a floor on the whole area, applied before any screen's own
check, and not a reason to remove that check.
The types themselves stay documented once, next to users.usertype in the Authentication
guide, and the new section links to them rather than copying the table.
Two reported bugs: a date of zero, and a cropped PNG's black corners¶
Helpers::getTime(0) returned now. The check was $time == NULL, and a loose comparison
against null is true for 0 and for '0' — so a record whose date column held zero rendered
as the current moment. That is the worst answer available: a row with no date looked like the
newest one on the page, and a listing sorted by the formatted date put it first. It now tests
for null and for anything non-numeric — '' and false out of an empty column still mean
now, both because there is nothing else they could mean and because '' + 3600 is a TypeError
on PHP 8. A caller that needs to tell "no date" from the epoch has to do that before
formatting; this function cannot, and pretending it could was the bug.
A cropped PNG came back with black corners. ResizeTools::_crop() scales the source onto
an intermediate canvas before it reaches the thumbnail, and a truecolor canvas starts opaque
black. The thumbnail is prepared for transparency; the intermediate was not, so alpha was
composited away one step earlier. Only on the cropping path — the same image resized without
cropping was fine, which is why it was reported as a crop bug rather than an alpha one. The
intermediate is now prepared the same way for PNG output; the JPEG path composites exactly as
before, since a JPEG has no alpha to keep.
A dead imagecreatetruecolor() went with it: _crop() allocated a second full-size canvas,
assigned it to a local, and never read it.
Something runs the second-factor cleanups¶
auth:twofactor-cleanup, daily and off-peak: authserver.twofactor_email_codes (one row
per mailed code, each holding the HMAC of a code that stopped working ten minutes later) and
authserver.twofactor_setup (the half-finished enrolments, plus the finished ones, which are
dead the moment they are used).
Both cleanup methods already existed. Neither had a caller anywhere — the same way
cleanupAllAuthTokens() sat unused until auth:token-cleanup was written for it. That is the
pattern worth naming: a cleanup with no caller has no symptom. Nothing fails, nothing slows
down noticeably, and a table of expired secrets simply keeps every row it has ever written
until somebody asks about it in an audit.
Deleted rather than retired, unlike a token: a spent code is not an audit trail. What happened
is in user_activity_log, and the hash of a code proves nothing about it.
The command sweeps each table separately and reports per sweep, because the two arrive with different migrations — an installation mid-upgrade has one and not the other, and a sweep that stopped at the first missing table would silently stop sweeping the one that is there.
The services screen says whether anything is listening to its buttons¶
/admin/Services lists what the orchestrator manages and offers Stop, Start and Restart. None
of those spawns or kills a process: they write and remove a sentinel file, and the
orchestrator acts on it on its next cycle. Which means that with no orchestrator running,
Stop still works — a daemon polls its own stop file — and Start and Restart do nothing
whatsoever. No error, no message. The operator clicks, the page reloads, the service is
still down, and the screen is what looks broken.
The supervisor's own state is now above the list: running, with its pid and how long ago it
last cycled; stale, when the pid is alive but nothing has cycled for two minutes, which looks
exactly like healthy if you only read the pid; or a warning naming the consequence when it is
not running at all. GET /admin/Services/status carries the same reading as orchestrator —
the thing a monitor should check before it counts workers, because with the supervisor gone
"0 running" is the expected number.
DaemonOrchestrator::status() was written for this — its docblock says "so an admin/API
endpoint can report 'is the supervisor up'" — and nothing called it. The screen could not: a
web request cannot construct the application's orchestrator subclass. So the two paths it
needs are static now, stateFilePath() and orchestratorLockPath(), and the controller's own
literal copy of the state file's path is gone with them.
A message to many accounts, composed and sent from a screen¶
massmessages and massmessagerecipients shipped with the messaging feature, with a model
each. Nothing composed one, nothing sent one, nothing displayed one — so an application that
wanted to tell its users something wrote its own loop, in a controller, inside a request.
/admin/MassMessages composes, counts, queues and reports; MassMessageDispatcher delivers;
messages:dispatch runs it every five minutes.
Queueing and delivering are separate because they fail differently. Queueing writes one recipient row per account and returns. Delivery takes them in batches and marks each row as it is attempted, so a send interrupted by a timeout or a deploy resumes without sending anything twice — and "how far did it get" is a row count rather than a guess. A send of four thousand emails inside a POST is a request that dies halfway with nobody able to answer that.
Queueing is the step that must not repeat, so a message that already has recipients is refused. Everything else on the screen is recoverable; that one reaches every person on the list.
The audience is resolved once, at queue time, from criteria stored on the message. Re-resolving at delivery would quietly include accounts created after somebody approved the send. The compose form shows the count before the button, which is the number that changes an operator's mind and the one nobody has when the send is a loop in a request.
Push is refused rather than skipped. The framework has no push transport, so every recipient of a push broadcast is recorded as failed. An operator who chose it is owed "there is no transport for this", not a message that reports itself sent to nobody.
One thing found while writing it: messages.attachmenttext is TEXT with no default, which
under MySQL's strict mode is NOT NULL with nothing to fall back on. An insert that omitted it
failed, the failure was caught per recipient, and every delivery would have been recorded as
failed with the reason only in a log.
Three test classes were spending their time emptying the cache¶
The suite ran in 2:55. Three classes accounted for 36 seconds of that, and none of it was work the tests needed:
DashboardControllerTest, 15.2s over 19 tests. Thecache()action enumerates every category and reads up to fifty items from each; the test's owntearDown()then cleared the store. Against the file cache that meant walking — and deleting — whatever the rest of the suite had written, per test, so every class afterwards paid to refill a cache this one had emptied. It now runs against an in-memoryArrayAdapter, installed for this class and restored afterwards: 0.65s.
That also makes the assertions mean something. What the screen reported depended on what other tests happened to leave behind, so "the screen lists the namespaces" was true or false according to test order. The one test that relied on those leftovers now seeds its own category.
-
SessionExchangeMintTest, 13.8s over 14 tests — and 0.7s when run alone, which is the tell. A fullcacheflush()per test is cheap on an empty cache and expensive on a hot one. Flushinguserlist, the categoryUser::load()actually reads through, is 0.68s in either case. -
TokenActionMySQLTest, 7.7s. The same full flush, forToken::load()'s 3600-second cache.usertokensinstead: 0.70s.
Suite: 2:55 → 2:26. The pattern worth remembering is the second one: a test that is fast alone and slow in the suite is not doing slow work, it is paying for everything the suite did before it.
Requiring a second factor, and requiring a real one¶
auth.security.require_second_factor_from_usertype has made a factor a condition of signing
in for a while, and it cannot lock anybody out: an account above the floor with nothing
enrolled is asked for a code by email, which every account can satisfy. That is deliberate —
enrolment happens after signing in, so refusing the mail would be a lockout by design.
It also means the switch on its own leaves an administrator holding nothing but a mailbox, for ever. A mailed code is the weakest factor here: one mailbox compromise from being no factor at all, on exactly the accounts worth the most, and the password reset arrives at the same address.
require_factor_enrolment_from_usertype is the other half, with
RequireFactorEnrolmentMiddleware behind it. Set to the same number, an account at or above
that usertype has every page redirected to the second-factor setup screen until it holds an
authenticator, a passkey, or an adaptor scoring at least FactorEnrolment::MIN_STRENGTH (40 —
above the mailed code's 20). The mail becomes the on-ramp rather than the destination.
Three properties make it a wall rather than a trap, and each is a lockout if it goes:
- The doors out stay open: the setup screens, the passkey endpoints, the sign-in flow, the
account area, signing out, the API, the discovery documents, health, assets. The allow-list
matches whole path segments, not substrings —
logo.pngis notlogout, and a substring test is how an exemption ends up quietly wider than it reads. - It fails open. No session, no application, a store that will not answer: the request goes through. Guessing the other way redirects every administrator in a loop, and the screen that would fix it is one of the ones being redirected.
- There is a way back from a terminal, which is not optional once a wall exists: the person who lost their authenticator cannot reach the screen that would fix it, and neither can the colleague who would help them.
So two commands, and they are part of the feature rather than an afterthought:
php pramnos auth:twofactor-status --missing # who the wall will stop, before you set it
php pramnos auth:twofactor-status admin # what one account holds, and what it owes
php pramnos auth:twofactor-reset admin # clear an enrolment so they can enrol again
--missing is the one to run before switching the wall on: turning it on without knowing
who is behind it is how an operator finds out from a support ticket. And neither command ever
prints a secret, a QR URI or a backup code — a support command that can read out an enrolment
secret is a support command that can enrol an attacker over the phone. The person re-enrols
themselves, from their own session, on their own device.
The debug bar says where the second factor stands — and stops forgetting your tab¶
Two complaints from the same afternoon, and one of them turned out to be a lockout.
The bar forgot which tab was open on every page change. It remembered whether it was
hidden and how tall it was, and forgot the one piece of state a developer is in the middle of
using — so following a bug across three screens meant reopening the same tab three times. The
open tab is now kept in sessionStorage: "where I was a moment ago" ends with the tab, while
hidden and height stay in localStorage, because those are preferences.
The Auth tab now carries the second factor. What the account holds, whether the site requires one of it, whether it is behind the enrolment wall, and during a step-up: the methods demanded, whether a mailed code is live, how long the resend has. Both questions it answers are otherwise unanswerable from the page — why am I being asked for a code, and why does every page redirect me to the setup screen. The second reads as a redirect loop from outside, and the first guess is always a routing bug.
The codes themselves are behind debug.reveal_factor_codes, off unless an application sets
it: the authenticator secret, a code valid now, and the last six digits mailed. The case for
showing them is good — the panel renders only where debugging is on, and everything in it
belongs to the viewer's own session — but this payload rides on responses, sits in a network
log, and ends up pasted into bug reports, where a live code is still a live code.
And the lockout the panel found in its first minute¶
methods: [] on a step-up page that was demanding a mailed code. The floor
(require_second_factor_from_usertype) puts an administrator with nothing enrolled on that
page and demands the one factor every account can satisfy — and sendFactorChallenge()
refused to send it, because it asked isEnrolledFor() and the account had never enrolled the
email factor.
So: a page offering a button that could not work, no other way in, and nothing saying why. The promise in the floor's own docblock — "it cannot lock anybody out" — was one condition away from being false, on exactly the accounts the floor exists for.
A demand now authorises a send, and the demand is carried in the pending session state rather
than recomputed: only the login that started knows what it demanded, and the new-device policy
can demand the same method from the same kind of account. pendingFactors() reports the union
too, so a screen asking "what can be completed here" is no longer told "nothing" on precisely
those accounts.
Three dead ends on the administration screens¶
"See all" on an account's mail panel opened the whole log. It now carries the address, and the mail screen filters to it — on the datatable's own source URL, not only the page's, because the table fetches its rows itself and a filter that lives in the page's query string alone vanishes on the first sort. The filtered screen says what it is showing and offers a way back to all of it: a filtered list is indistinguishable from a short one, and a short mail log reads as "nothing was ever sent" to somebody checking whether a code was delivered.
The value is quoted by the driver rather than concatenated. Datasource::getList() takes SQL
rather than bindings, and the address arrives in a query string.
The dashboard's four figures were four dead ends. A number on a dashboard is the start of a question — why is that high, which accounts — and a box that cannot be clicked sends the reader hunting through the sidebar for the screen that answers it. Each is now a link to that screen.
And an operator could not reach their own record. There are two "me" in an installation like this: the end-user account area, where somebody changes their own password, and the administration area's record of the same account — its sessions, tokens, activity, permissions, the mail it was sent. The header menu offered only the first; the second was reachable by typing the id into the URL.
A settings row no longer opens the debug toolbar¶
There were five ways to turn the toolbar on: a signed token, APP_DEBUG, the DEVELOPMENT
constant, the debug setting and the development setting. The last two are rows in
the settings table, editable from /admin/Settings, and flipping either one turned the
toolbar on for every visitor of the site — not for the person who flipped it.
What the toolbar carries makes that an escalation rather than an untidiness: every query with
its bindings, the session's keys, the request's authentication state, the resolved route and
middleware. A row in a table nobody thinks of as dangerous is not the right lock for that. And
the rows were redundant: a development environment says so through APP_DEBUG or the
constant, and a developer on a live server has debug:token — signed, single-use, expiring by
itself, and scoped to one browser.
So the toolbar's gate is now token, environment, constant. The settings still mean what they
always meant elsewhere — error display, the DevPanel, the debug log — and debug:status says
so in as many words, because an operator who sets debug and reads "Toolbar active: ON" draws
the opposite conclusion from the truth.
Two things came out of writing it:
The question was asked in two places with two different answers.
Application::registerServiceProviders() decided whether to load the provider,
DebugBarServiceProvider::isDebugEnabled() decided whether to boot its collectors, and the
expressions were not the same — so an installation could satisfy one and not the other and end
up with a provider registered and inert. There is one toolbarAllowed() now, and all three
call sites ask it.
getenv('APP_DEBUG') does not see .env. symfony/dotenv populates $_ENV and
$_SERVER; it does not call putenv(). So the environment check answered "not set" on a
project whose .env says APP_DEBUG=true, and the toolbar was arriving through the settings
path instead — the one being removed here. It reads envvar() now, which reads what dotenv
actually wrote.
And the setting moved to where it still means something. After the toolbar stopped reading it, "Debug Mode" sat among the general settings deciding one thing: whether the DevPanel opens (behind its own usertype floor) and whether the debug log is written. A checkbox called "Debug Mode" in the general tab is read as "show me the developer tools", so it is on the DevPanel tab now, next to the floor it works with, saying in as many words what it does and does not do.
Moving it exposed the failure mode of moving any checkbox into a conditional pane: the tab renders only where the feature is enabled, an unchecked checkbox submits nothing, and the controller therefore could not tell "turned off" from "that tab was never on the form". Writing it unconditionally would have switched the setting off on every save made by an installation without the DevPanel — a switch reset by saving an unrelated field, which nobody connects to the save they just made. A hidden companion field says the tab was there.
The page's own disclosure went with it. View wrote the rendered template's path into an
HTML comment on every page, gated on isDebugMode() — so the same settings row decided whether
every visitor's page source, and every crawler's copy of it, said where the application's files
live. That now asks Application::isDeveloperEnvironment(): the environment variable or the
DEVELOPMENT constant, and deliberately not a debug token either. A token opens the toolbar for
one browser on purpose; it should not start rewriting the HTML everybody else's cache will hold.
Nothing on the settings screen opens the DevPanel any more¶
The DevPanel browses the database, reads the cache, lists sessions and dumps the container.
It could be opened by ticking "Debug Mode" on /admin/Settings — a row in the settings
table, editable by anybody who could reach the administration area, on a live server, with no
deploy and nothing in the repository to say it had happened.
The environment is the only lock now: APP_DEBUG=1 in .env, or the DEVELOPMENT constant.
Both take shell access and a restart, and both are visible in the deployment rather than in a
table.
This also settles something nobody could explain from outside. The panel stayed open with
"Debug Mode" unchecked, because the gate read a second setting — development — which has
no field on any screen. And getenv('APP_DEBUG') was asked directly, which answers "not set"
on a project whose .env says otherwise, since symfony/dotenv writes $_ENV and never
calls putenv(). So the panel was opening through an invisible row by accident rather than
through the environment on purpose. One definition of "is this a development deployment" —
Application::isDeveloperEnvironment() — is what everything asks now.
The debug setting still exists and Application::isDebugMode() still reads it, for an
application that wants a switch of its own. It no longer opens anything the framework ships,
and it has no field on the settings screen — a checkbox that decides nothing is worse than no
checkbox.
The two DevPanel fields moved to app.php, and never saved anyway¶
Where the panel is mounted and who may open it are properties of the deployment, so they are read from the config, next to the line that enables the feature.
They had fields on the settings screen, and neither field ever saved. PHP replaces . with
_ when it builds $_POST, so devpanel.mount arrived as devpanel_mount; the controller
asked for the dotted name, found nothing, took its default and wrote that. Every save reset
the mount point to devpanel and the floor to 90 — including a save of the site name on a
different tab, which is a value nobody typed being reset by a form that never carried the
field.
DevPanelController::config() is the one reading, so the panel, the debug toolbar's link to
it and the service provider's sanity check can no longer disagree about who may open it — the
link used to appear for a user the panel then answered 403 to.
A scaffolded application comes with something that runs its background work¶
init wrote the queue, the messaging tables and the schedule, and no process that touched any
of them. So a fresh project's background work did not happen — in development, and then in
production, where nobody had ever seen it happen.
That failure is quiet by construction. It never appears as "the worker is not running": it appears as a screen with no rows on it, a job that stays queued, a scheduled cleanup that never ran. Each of those reads as a bug in the code that would have used the worker, and gets debugged there.
When the enabled features have background work — queue, messaging, broadcasting, or the
periodic jobs auth and authserver schedule — the scaffold now writes three things:
src/ConsoleCommands/Daemons.php, aDaemonOrchestratorsubclass nameddaemons:start, declaring the queue worker when the queue is enabled;- its registration in the application's
Console; - a
daemonsservice indocker-compose.yml, sharing the app's image and volume so it runs the code the site is running.
restart: unless-stopped, not on-failure, and that is the interesting line. This container's
worst failure is a clean exit: a machine that comes back before the database is accepting
connections boots the framework into its maintenance page and returns 0. on-failure looks at
that and correctly does nothing — so the supervisor is simply gone, with every other container
up and healthy beside it, which is why restarting the rest changes nothing.
/admin/Services says how to create the supervisor¶
The banner on that screen told an operator to "run the orchestrator", which is not an
instruction anybody can follow from a web page. It links to the
deployment section
now, which grew a complete Ubuntu / Debian walkthrough: the systemd unit with Restart=always
and why it is not on-failure, KillMode=mixed because it supervises children, the two things
that go wrong on a fresh box (var/ not writable under www-data, and a leftover
schedule:run crontab making every scheduled job run twice), and the Docker equivalent.
New-sign-in alerts can be on unless turned off¶
auth_newsignin_policy takes a fourth value, optout: the account's own preference still
decides, but silence now means yes.
Under optin — still the default — the people who most need to be told their credentials were
used from a new device are the ones who will never find the checkbox. A security feature whose
protection is opt-in ends up protecting the users who were already careful.
A new project starts there: pramnos init writes 'newsignin_policy' => 'optout' into
app.php, which the framework reads as the default when the setting has no value — so
/admin/Settings still overrides it, and an existing installation changes nothing by
upgrading. optin remains the framework's own default for exactly that reason: a patch release
must not start mailing somebody's user base.
optout changes only where the choice starts from. It is possible because the preference
stores '0' instead of deleting its row: "chose no" and "never chose" are different states,
and this policy is the difference between them. An account that turned the mail off stays off.
A failed read still answers no, under optout too. A query that cannot be executed is not
consent, and a login storm during a broken userdetails would otherwise mail the whole user
base.
The DevPanel's Back button goes where you came from¶
It went to the site root. You open the panel from the screen you are debugging — deep in the administration area, often with a filter in the query string — read one number, press Back, and land on the home page with the way back to what you were doing gone.
The referrer is recorded once on the way in, and only when it is not the panel itself: the tabs across the top are same-panel navigation, so the original page survives moving between Database, Cache and Users. Only a URL on this site is kept, and the stored value is re-checked on the way out — it is rendered into a link an administrator will click, so a foreign one would make the panel a way to send somebody somewhere else with the panel's own appearance vouching for it.
Session can write, not only read¶
Http\Session::get() existed and set() did not. That is the kind of absence nothing
catches: a caller writes $session->set(...) because get() is there, and gets
Call to undefined method — a fatal at runtime, on whichever request reaches that line.
One was found in production, on a path a full-page cache had been hiding: the parameter that
triggered it was in the cache's ignoreQuery list, so the code ran only on a cache miss and
the 500 looked sporadic rather than like a dead path.
set($key, $value), has($key) and remove($key) complete the set. set() calls
ensureStarted() first — in lazy-session mode a write can be the first thing that needs a
session, and without it the value would be assigned to an array that is never persisted and
read back as null: a write that silently does nothing, which is worse than the fatal it
replaced. has() and remove() start nothing, deliberately: asking whether something is
stored must not hand a cookie to a visitor with no state.
One apostrophe no longer destroys a page's breadcrumb structured data¶
Html\Breadcrumb built its BreadcrumbList JSON-LD by concatenation and escaped labels with
addslashes(), which also escapes the single quote — and \' is not a valid JSON escape
sequence. So a label containing an apostrophe made the whole list unreadable, not just the
entry it appeared in: json_decode() stops at the first one.
Breadcrumb labels are user data — a person's name, a place, a category title — so this was the
common case rather than the awkward one. And nothing saw it: the visible <ol> renders
identically, the page is 200, and an HTML snapshot comparing the same broken text agrees with
itself byte for byte. The only reader that noticed was a search engine, which cannot tell you.
The structure is built as an array and encoded now, which also closes two neighbours: the
item URL was interpolated with no escaping at all, and JSON_HEX_TAG stops a label
containing </script> from ending the element it is inside.
The title and href attributes of the visible trail are escaped too — a title defaults to
the label, and one double quote in a name would end the attribute and make everything after it
markup the visitor chose. The label's own text is still rendered as HTML, deliberately and by
long-standing contract: callers pass markup, which is why the structured-data name is
strip_tags()d.
Html\Date renders the time and the dropdowns it always claimed to accept¶
Date::getDate() read $this->time and parsed a {name}_timepicker value. render() never
emitted that field, and the property was not declared — so $date->time = true went into
Base::__set()'s bag, raised nothing, and every form asking for a time received midnight.
Declared and implemented now, along with the second missing group: calendar = false renders
three dropdowns instead of a datepicker, with dropdownYear, dropdownLabels and
dropdownRequireSelect. A birth date is what those are for — a datepicker asking somebody to
page back forty years is worse than a list — and dropdownRequireSelect is what keeps an
unanswered field from coming back as a real date the visitor never chose.
The time field is a new Html\Time: a native <input type="time">, usable on its own,
submitting HH:MM — which is exactly what getDate() has always parsed. It replaces three
select boxes wired to a Spry validator, a library that has not shipped in years and whose
stylesheet was the only thing keeping its four error messages off the page.
The queue worker runs under a supervisor¶
Three separate reasons it could not, all found by putting a DaemonOrchestrator in front of it
on a real installation.
It demanded a controller that did not exist. queue:process asks the application for
Queueitems — the administration screen for queued jobs — because Application\Model will
not be constructed without a Controller to hold. An application without that screen got
«Cannot find controller: Queueitems» from a background worker, at start-up, about a UI class it
was never going to render anything with. Under a supervisor: respawn, fail, repeat, with a
queue that never drains. QueueManager::controllerOrPlain() falls back to a plain Controller,
and an application that has its own still gets it.
It watched the wrong lock file. The daemon loop checks "is my lock still there" on every
pass, and rebuilt the path from this command's own default — while the orchestrator hands the
real one down in PRAMNOS_JOB_LOCK_FILE and writes the .stop sentinel beside it. So under
any orchestrator naming its own lockFile, the file did not exist, and the worker announced
«stop signal detected» and exited on its first pass. For ever. The only visible symptom was a
queue that never drained — the worker's own log said it had been asked to stop, which is a
sentence nobody doubts. Three places in that file were still computing their own answer;
resolvedJobLockFilePath() exists precisely so they cannot.
It painted a terminal UI into its log. The dashboard clears the screen and repaints in
place, which in a supervised worker's log file is thousands of escape sequences and no readable
history — and that log is the only account of what the worker did. isSupervised() (also read
from PRAMNOS_JOB_LOCK_FILE, because stdout is not a terminal when piped either, and the
parent pid is init after setsid) skips it. The live numbers belong on /admin/Services.
/admin/Services can see a supervisor in another container¶
Whether the orchestrator is running was answered by its pid, and a pid only means something
inside the namespace that issued it. Its normal home is a container of its own — that is what
pramnos init now writes for an application with background work — so the number in the lock
file belongs to that namespace and the web request reading it is in another. There it matches
something unrelated or nothing at all: a working supervisor reported dead, or a dead one
reported alive because pid 14 happens to be Apache.
The reading is now the pid or a heartbeat younger than 120 seconds. The state file lives on the shared volume and is rewritten every reconcile cycle, so a recent mtime means not merely alive but actively cycling — and it cannot lie in the direction that matters, because a stopped supervisor stops touching it. A live pid with a stale heartbeat still counts as running, deliberately: that is the stuck-supervisor state, and the screen has a warning for it that says more than silence would.
An unsubscribe link, and the two headers Gmail actually reads¶
Email::$unsubscribe existed and emitted List-Unsubscribe with whatever string a caller set.
That is not a header value — RFC 2369 wants each entry in angle brackets, and a bare URL is
ignored. Silently, because nothing in mail reports a malformed header back to the sender. The
header was there, read correctly in a dump, and did nothing.
List-Unsubscribe-Post was missing entirely, and it is the half Gmail and Yahoo require of
anyone sending in volume. Without it they draw no unsubscribe control, so the reader's easiest
way out of a list is the spam button — which is counted against every future message, including
the password resets.
One call, because the four things it sets have to agree: the URL, the mailto: alternative,
the one-click promise, and the list name the wrapper renders a visible link from. A
List-Unsubscribe-Post over a URL that shows a confirmation page is worse than no header — a
provider follows it, gets a page, and counts the message as unhandled.
/unsubscribe is a public framework controller, and has to be: a one-click request comes
from a provider's server with no session, and an address on a list does not always have an
account. POST unsubscribes and answers 200 with no confirmation step (RFC 8058); GET is a
person and gets a page. It is exempt from CsrfMiddleware by default — Gmail has no token to
send, and the signed URL token is the better authorisation here anyway. A record that could not
be written answers 500, so the provider retries, rather than promising something that did not
happen.
The token is signed, not stored. Nothing is written when a message is sent — a million-recipient send would otherwise write a million rows for links most people never open. No expiry either: people unsubscribe from a message they found six months later, and "this link has expired" is a sender making its own problem the reader's.
A notification says which list it belongs to, and one that says nothing is transactional and gets none of this — no link, no header, no suppression. A password reset must arrive even for somebody who unsubscribed from everything.
newsignin is the exception, and the reason the mechanism has handlers: the alert already has a
checkbox on the account's privacy screen, so honouring an unsubscribe flips that. A
suppression row the screen knows nothing about would stop the mail while the switch still said
it was on.
isOptedOut() answers true when it cannot tell — alone among this framework's reads.
Sending to somebody who unsubscribed is the mistake a provider counts against everything else
you send; a message not sent during an outage is one the next run sends.
Mass messages carry all of it, and skip an address that has used it.
TestClient populates $_REQUEST¶
PHP builds it from $_GET/$_POST at start-up, so in a real request it is simply there — and
Request::get() reads it by default. A test that set only $_GET left every
$request->get('x') reading an array from whenever the test process began: the parameter
arrived, the controller could not see it, and what came back was the missing-parameter branch.
Found through the unsubscribe endpoint, where that branch renders «this link is not valid» about a link that was fine.
Addon::trigerAddon() refuses a nameless addon too¶
The third reader of the same registry, left unguarded when the other two were fixed. null
arrives from real data — the addons setting holds a serialized list and an entry saved with no
name selected stores null — and isset($array[null]) is a deprecation on the way to
answering "no", which was the answer either way.
Reported by a project that had removed its overrides of isActive() and getAddon() and had to
keep the one for this method. Guarding two of three is the asymmetry that gets found later, by
somebody else.
The default language is a list, not ten characters of free text¶
A typo in default_language is not a validation error. The catalogue is simply not found,
Language falls back to English, and the setting reads gr while every page is in English with
nothing anywhere saying why — Greek is el.
The field is now a <select> built from Language::getLanguages(), which reads the same three
directories load() does. A stored value with no catalogue behind it is kept as an option and
labelled «(no catalogue)», so saving an unrelated field cannot silently change the language, and
the interesting part — that the value names nothing — is on the screen. With no catalogue at all
the field stays free text: better one that works than a dropdown with nothing in it.
Adminer, at /adminer, behind the application's own gate¶
The framework serves it, and the DevPanel's Database tab links to it when the package is there.
suggest, not require: a framework that shipped a database browser into every application's
vendor/ would enlarge the attack surface of applications that never asked for one. Absent, the
route answers 404 like any other unknown address.
The alternative it replaces is what people actually do — a PHP file in the web root, on a URL anybody can guess, protected by whatever the database password happens to be, still there a year later.
Who may open it: usertype ≥ 99 — Root — on any deployment, including production, or a development
environment subject to the DevPanel's floor. Production access for root is deliberate: fixing
data on a live server is a real thing an owner does, and a tool that only works in development
means they do it in psql with no undo. Everybody else gets a 404 rather than a 403 — a 403
confirms the route exists, and this is the one URL where that is worth withholding.
Adminer keeps its own database login. Filling it in would make "may this browser reach a URL" the only thing between somebody and every row, and the accounts that can reach it are the ones whose sessions are worth stealing.
Two things the plumbing has to do: its assets live in vendor/, which no web root serves, so
the output's ./static/… links are rewritten to ?file=… and served from the package
(whitelisted and resolved — a whitelist that allows dots is one somebody gets through); and the
route sends its own CSP, because the site's is nonce-based and Adminer is full of inline
onclick handlers that a nonce policy blocks whatever the nonces say.
One thing it cannot do: be tested in-process. Adminer is a script and a script ends with exit,
which takes the test runner with it. The guard, the asset serving and the URL rewrite are tested;
the rendering is the browser's job.
TestClient reports the status the request set¶
A controller answering with http_response_code(404) and an echo — how the framework's public
endpoints answer, since they render no view — came back from TestClient as 200, because it
built its own Response and never asked PHP what the request had decided. So no test could
assert the one thing a monitor, a crawler or a mailbox provider reads.
/messages — the inbox those internal messages were going into¶
/admin/MassMessages can send a broadcast as an internal message: one row per recipient in
messages, and the progress screen reporting every one of them delivered. Nothing displayed any
of it. «Delivered» meant «written to a table nobody looks at», and no recipient could read a
word.
Every part of the machinery was working, which is why it survived: the insert succeeded, the count was right, the administration screen was honest about what it had done. Only the reader was missing, and no test notices a reader that was never written.
So there is one now — a list, one message per page, reading it marks it read. Not a mail client:
no compose, no reply, no folders. pramnos init writes the application's src/Controllers/
Messages.php beside the mass-message wiring, and the framework registers the navigation item
when the messaging feature is on.
messages.type is one column carrying several unrelated meanings — read, sent, archived,
deleted, notification — so the listing names the states it wants rather than excluding the ones
it does not. A NOT IN (deleted, sent) would put a state added next year into everybody's inbox,
and the screen would look exactly as correct as it does today.
Message::countUnread() was reading a table that does not exist¶
FROM messages, with no prefix. The right name on an installation that configures none, and a
table that does not exist on any other — so the count was zero for everybody, for ever, on
exactly the installations that set a prefix. It had no caller until this screen, which is why
nobody found out. Both counters go through the query builder now.
Any browser with JavaScript can solve the human check — and a test proves it¶
The proof-of-work check needed crypto.subtle, and crypto.subtle is only available in a
secure context: HTTPS, or localhost. A site reached over plain HTTP by hostname or LAN address
— a staging box, a colleague's machine, a tablet on the office network — has none of it. The
client then rejected, the form submitted an empty solution, the server refused it, and the
visitor read «your browser must support JavaScript» while using a browser that supported it
perfectly well.
It happened on a login form, which is the worst place for it: the person cannot get in, and the message tells them to fix something that is not broken.
There are four paths now, and all four produce the same answer:
| Hash | Where | |
|---|---|---|
| 1 | crypto.subtle |
a Web Worker |
| 2 | the framework's own SHA-256 | a Web Worker |
| 3 | crypto.subtle |
the main thread |
| 4 | the framework's own SHA-256 | the main thread, in slices |
A worker that will not start — a policy without worker-src blob:, an extension, a webview that
reports the constructor and refuses the URL — falls back rather than failing. The main-thread
search is sliced with setTimeout, because a loop on the thread that draws the page is a tab
somebody force-quits. The SHA-256 is written once and handed to the worker with String(), not
new Function, which the framework's own policy forbids.
The part that matters: it is a test, not a claim¶
The client and the server have to agree byte for byte — the payload with the signature stripped,
: between payload and candidate, SHA-256, leading-zero bits, base-36 for the candidate. Any
one of those being wrong looks identical from production: a refused login, blamed on the browser.
HumanCheckClientAgreementTest loads scaffolding/assets/js/pf-humancheck.js — the exact bytes
a browser is served — under Node, runs it down all four paths with each path's capabilities
withheld the way an old browser withholds them, and verifies every answer with
HumanCheck::verify(). The pure-JS digest is compared against a real one over empty input,
ASCII, a long string, Greek text and an emoji.
Change the separator in that file by one character and five of its six tests fail.
A PHP reimplementation of the hash would have agreed with itself and proved nothing. The only useful assertion is the one that makes the shipped client do the job.
init also copies every pf-*.js by glob now. Three were listed by name and
pf-humancheck.js — added later — was not among them, so a scaffolded project got the markup
for the check, the server-side verification of it, and no script to solve it.
A CSP-blocked redirect, and a script with two nonces¶
Two ways the framework's own Content-Security-Policy was refusing the framework's own scripts.
Application::redirect()'s fallback had no nonce. When output has already started the
Location header is gone and the page has to move the browser itself, with
<script>window.location=…</script> — refused by a nonce-based policy, silently, leaving the
visitor on a page that had already decided to send them somewhere else. The nonce injector could
not help: it runs inside the HTML document's render, and this is echoed straight to the output
stream. The URL was also interpolated raw into a JavaScript string and an HTML attribute, so a
destination carrying a quote closed the string and the rest was code.
Scripts that carried their own nonce were given a second one. humanCheckField() and its
neighbours emit a nonce because they write a script outside any document render; the injector
then added another, producing <script nonce="X" nonce="X"> — a duplicate attribute, which is a
parse error, around the most security-sensitive inline script on the page. A tag that already has
one keeps it.
Adminer signs itself in¶
/adminer asked for a server, a username, a password and a database that the application already
knows — it is connected to that database right now. Retyping them means keeping the production
database password somewhere copyable, and a sticky note is what actually happens.
AdminerBridge supplies them through adminer_object(), the hook Adminer looks for, and seeds
the password into Adminer's own session. Off with
'devpanel' => ['adminer_autologin' => false].
A refused request goes through Application::notFound() — the site's own 404 — rather than
printing «Not found». A refusal has to be indistinguishable from an address that does not exist,
and a page nothing else on the site produces tells whoever is looking that something is here.
Where the connection goes took three attempts, and the first two are the interesting part.
$_GET alone gave ERR_TOO_MANY_REDIRECTS on the first click. Adminer builds its own self-links
from $_SERVER['REQUEST_URI'] (ME, relative_uri(), remove_from_uri()), so with the
parameters in $_GET and absent from the URI, its idea of "the canonical address of this page"
was the bare route — which it redirected to, arriving back here, where they were injected again.
A real 302 to that canonical URL fixed the loop and wrote the driver, the host, the username and the database name into the address bar, the browser history and every access log on the way. Reported immediately, and correctly: the password was never in it, and the rest is still more than a URL should say about somebody's database.
What is there now aligns the request URI Adminer reads with the $_GET it reads, together, and
leaves the visitor's address bar at /adminer. The schema goes in too — without it Adminer
redirects once more to add ns=, which is its own correct behaviour and publishes the connection
on the way. Referrer-Policy: no-referrer covers the rest: Adminer's own links do carry the
connection, because that is how it identifies one, and the version check in its footer points at
adminer.org.
$_SESSION is emptied, not only closed. session_write_close() writes the data and closes
the handle; the array stays in memory. Adminer starts a session of its own only when none is
active, and if anything leaves one active it reads and writes our keys — token among them,
which is this framework's CSRF token and Adminer's rand() ^ $_SESSION["token"]. That produced
«A non-numeric value encountered» twice per page and a CSRF check that could not work.
Three things were broken in the first version, all of them worth naming:
- No stylesheet. Adminer is a script and a script ends with
exit, so the code after theincludenever ran and the asset-URL rewrite was skipped. PHP flushed the buffer at shutdown with Adminer's own./static/default.csslinks in it, which resolve to nothing under/adminer. The rewrite is a buffer callback now, whichexitcannot skip. Session ini settings cannot be changed when a session is active. Adminer configures session ini values and starts its own session only if none is active. Ours was, so it warned at the top of every page and then wrote its keys into our namespace.A non-numeric value encountered, twice per page. One of those keys is$_SESSION["token"], which this framework also uses. Adminer's CSRF token isrand() ^ $_SESSION["token"]; ours is a hex string. Our session is closed before Adminer sees the request, so it gets theadminer_sidnamespace it expects.
Html\Date reads the properties it declares¶
changeYear: true was hardcoded into the datepicker's options while $changeyear sat declared
and unread — a property that could be set and did nothing, which is worse than one that does not
exist, because the caller believes the widget was configured. It is read now, and $changemonth
joins it.
And onChangeMonthYear keeps the chosen day, permanently rather than behind a flag: pick the
31st, change the month to February, and the field said 31/02. What happened next depended on
the receiving end — strtotime rolls it into March, a database refuses it — and neither is what
the visitor chose. The day is clamped to the last one the new month has.
($tabindex was reported as undeclared in the same round. It is declared — on Html, this
class's parent, where a global HTML attribute belongs.)
Locking it down for a public server¶
Two of the things that were true of the first version are worth naming, because both looked closed and were not.
Removing Adminer's login form did not remove the ability to log in. auth.inc.php acts on
$_POST['auth'] — driver, server, username, password, database — before anything else. Taking
the form away removed the page that submits it, not the submission: a hand-made POST, or a
form on another site aimed at this URL, could have pointed this Adminer at any host reachable
from the server with any credentials the sender knew. $_POST['auth'] and $_POST['logout'] are
discarded before Adminer sees the request.
A request naming another server was obeyed, because that is Adminer's design: the query
string says who to connect as. Behind a gate whose whole meaning is "this database", it must not.
The connections the installation declares — the primary and the read/write replicas — are an
allow-list, and anything else gets the default.
Beside those: every open and every refusal is now logged to the auth channel with the
account, the address and the URL. Adminer keeps no such record, and an access log says a URL was
fetched rather than which account fetched it. On a public server that is the question somebody
asks afterwards.
The bar is fixed rather than sticky — Adminer's pages scroll sideways whenever a table has a
long comment, and sticky pins only the vertical axis, which took Back off the screen — and
comment cells wrap, which is what made the page wide in the first place.
An idle connection is not a query running for three hours¶
/admin/Dashboard/database showed a «Running» column filled from now() - query_start for every
row — idle connections included, where that measures time since the connection last ran anything
rather than work in progress. A pooled connection idle for three hours read as 194m 6s in red
beside the word idle, so the screen looked like a stuck query every time it was opened. Two of
those and nobody reads the column again.
pg_stat_activity is asked for both numbers now: active_sec, the running query's own age, null
unless the backend is running one; and idle_sec, how long it has been idle. The column says
which it is showing, colours only a genuinely long-running query, and sorts active work first.
Html\Date's field is validated by the browser again¶
type stays text — a native date input submits YYYY-MM-DD and every receiving end parses
dd/mm/yyyy — which makes pattern the only validation a browser performs. It was missing,
along with title, inputmode and required: the dropdown branch had all four and the
datepicker branch, which is the one most forms take, had none. A required date field submitted
empty with the browser saying nothing, and the first thing to notice was strtotime() on the
server, which cannot tell the person typing.
yearRange follows minyear/maxyear too. It was hardcoded c-250:c+10 while the same two
properties were honoured in the other branch — one widget keeping its bounds in one form and
ignoring them in the other.
The level was wrong, and 100 is a number nobody has¶
The floor was written as 100. UserTypes::DEFAULTS tops out at 99 = Root, so the one person
this route exists for was refused by it on production — with the same 404 everybody else gets and
no way to tell the two apart. The test asserts it against UserTypes rather than repeating the
literal, because two numbers agreeing by coincidence is exactly how it went wrong.
An installation with its own scale sets 'devpanel' => ['adminer_min_usertype' => …].
Adminer's own links no longer carry the database username¶
Adminer identifies a connection in the query string — driver, host, username, database — so every link it draws published them into the address bar, the browser history and any log in between. Reported, and fair: the password was never there, and the rest is still more than a URL should say about somebody's database.
They are stripped from its links on the way out, and supplied again server-side on the way in.
The two halves are the same mechanism that stopped the redirect loop: the connection lives in the
configuration, not in the URL. Only the credentials-ish parameters go — table=, select=,
sql=, ns= and the rest are navigation and stay.
A date is written the way the language writes dates¶
date('Y-m-d H:i') was in about fifty views. It is the right answer in one language and the wrong
one everywhere else — a Greek page showing 2026-08-28 is not wrong the way a mistranslation is
wrong. It is read, understood, and quietly filed as software written by somebody else, which is
the whole reason a project translates its screens in the first place.
<td><?php echo localDate($row['created']); ?></td>
<td><?php echo localDateTime($row['created']); ?></td>
<td><?php echo localTime($row['created']); ?></td>
Pramnos\General\DateFormat picks the pattern from three places, in order, and each exists
because the one before it is not enough for somebody:
- the
date_format/datetime_format/time_formatsettings — a site-wide override; app.php—'dates' => ['el' => ['date' => 'j/n/Y']], per language, versioned with the code;- the framework's own table, so a project that configures nothing still gets Greek dates on a Greek page.
Not IntlDateFormatter: intl is not everywhere, and a formatter that renders a different date
depending on whether an extension happens to be compiled in is worse than a plain one.
0 is not the first of January 1970. A column with no date is the ordinary case in these
tables — an account that has never signed in, a message never sent — and 1970-01-01 in a listing
is a value somebody has to be told to ignore, worse in a sorted column where it sits at one end
looking meaningful. The helpers take what to show instead.
Fifty-one views across the three themes now use them. What was deliberately left alone: an
<input type="datetime-local"> value, a <time datetime> attribute and an export filename, all
of which are machine-readable and must stay ISO.
Two of the widest columns on the process list said the same thing four times¶
/admin/Dashboard/database listed User and Database per row, and the query filters on
datname = current_database() — so every row carried the same database, and in practice the same
user. The table needed a horizontal scrollbar, which put the query, the one column somebody is
reading, off the right edge.
They are in the card header now, once. A row that genuinely differs — a replica's user, a
background worker — gets a user @ database cell instead, so nothing is lost: those columns were
not uninformative, they were uninformative four times. Started uses the language's own format,
which is also shorter than the ISO string that wrapped onto two lines.
Two empty boxes where the log charts should be¶
/admin/logs draws two charts — entries over time, and the breakdown by level. On an
installation that had never registered Chart.js they were two empty bordered rectangles with
titles above them. No error, no console warning, nothing in the log: the view asked the
Document for the chartjs asset, the asset was not in the registry, the request was ignored,
and the <canvas> elements sat there being blank.
Blank is the worst of the three possible states. An empty chart is indistinguishable from a chart of an installation with no log entries, so the first reading is "nothing has happened here" — and that is a wrong answer to the question somebody opened the log dashboard to ask.
The dashboard now checks whether the asset is actually available before deciding what to render, and when it is not, it draws the same numbers as two tables plus a line saying why. The figures were never missing; only the drawing was.
All three scaffold themes carry the fallback, because the reason an installation lacks the asset is usually that nobody knew it needed registering — which is exactly the installation that will not diagnose two empty boxes.
The log dashboard's figures, asked for by something that is not a screen¶
LogController::dashboard() contained about a hundred lines of aggregation: walk every
whitelisted log file, merge the per-file analytics, add the levels up, deduplicate the errors
by message, sort them by frequency, work out each file's error rate. Genuinely useful numbers,
reachable only by a human with a browser and an administrator's session.
Which is the wrong shape for what they answer. «What is going wrong on this installation» is the first question anybody asks — a person, a monitor, an assistant with an MCP connection — and the answer existed in exactly one place that could not be called.
So the aggregation moved to Pramnos\Logs\LogAnalytics, and the screen became one of its
callers:
One implementation on purpose. Two copies of the same aggregation drift, and the day they disagree the screen and the caller each look right on their own — there is nothing to compare them against.
The other caller is the MCP server, which gained two tools:
log-analytics— the summary. The trend, the counts per level, the most frequent errors with how often each occurred, and the per-file error rate.log-errors— the entries themselves, newest first, defaulting to the levels somebody means by "the error log" and filterable by level, file, timespan or a search string.
Two rather than one because they answer different questions. A summary says whether something is wrong and how much; a hundred stack traces say nothing until somebody has read them. The alternative — the thing this replaces — was pasting a log file into a chat window, which is more work and less information: a paste has no counts, no rates and no idea what it cut off.
Three details in the summary are deliberate, and each of them is a wrong answer avoided:
topErrorsis keyed by the message, so the same failure appearing in three files is one row carrying the total, rather than three rows that each look survivable.truncatedis reported. A very large log file is scanned from the tail only, and a summary of the last 25 MB of a multi-gigabyte log otherwise reads as a complete picture.- An empty result carries a
note, because "no log files were readable" and "nothing has gone wrong" are the same empty answer, and only one of them means stop looking.
log-errors is bounded at 200 entries and says whether it stopped early — an answer that hit
its own limit reads as «that is all there is», which is the wrong conclusion for somebody
deciding whether a problem is over.
One thing was nearly lost in the move. The screen's timespan selector offered 6h, and the
first version of LogAnalytics::TIMESPANS did not have it. An unknown timespan falls back to a
day, so the option would have gone on working while quietly answering a different question —
the same numbers under the wrong heading, which is worse than an error. It is in the table, and
there is a test asserting that every timespan the screen offers is one the service knows.
The components guide listed Seo and then never mentioned it again¶
Every row of the component table links to the section describing it. Seo was a row with no
section — a class named in a table of contents and documented nowhere, which is worse than
being left out: the reader now knows it exists and has no way to find out what it does.
It has a section. canonicalLink(), jsonLd(), why the four JSON flags are each there rather
than a matter of taste, and the one rule that matters more than the API — absent is not
empty. Omitting a key you have no value for and emitting "genre": "" are different
statements, and a consumer reads the second as a claim that the field is blank.
Icon was missing from the table altogether while being documented in the datatable section,
so it gained a row pointing there.
mcp:serve had its own copy of the tool catalogue, and it was stale¶
The two log tools were registered in McpServiceProvider and the server went on advertising
seven. Launched the documented way, log-analytics and log-errors answered Unknown tool.
mcp:serve builds a server itself whenever no container has one — which is the normal case,
because the console reaches an application without initialising it — and that branch carried a
second, hand-written list of every tool. Adding a tool to the provider therefore did nothing
for the only way anybody actually starts the server.
Both lists looked correct on their own. That is the whole failure mode: there was nothing to compare them against, and no test could notice, because each was asserted separately.
McpServiceProvider::registerDefaults() is now the one list, and the command calls it. The
application-independent tools — the guides, the rule check, and the two log readers — register
even with no application, since a server booting without one is exactly when somebody is asking
how this works or why it did not.
The test that should have existed does now, and it does not count tools:
Two catalogues of the same size can hold different tools.
The most frequent error in the log was the framework asking a question¶
relation "permissions" does not exist, over and over, on an installation where nothing was
wrong. ApiCrudController::legacyAclExists() decided whether the legacy ACL table was there
by running SELECT EXISTS(SELECT 1 FROM permissions) and catching the failure.
On PostgreSQL a select against a table that is not there is an error on its way to being an
answer. The connection logs it before the catch in the probe ever sees it — so every API
request on an installation with no legacy ACL, which is every new installation, wrote a line
that read like a fault.
The return value was always correct. That is precisely why it survived: the answer was right and the log said something had gone wrong, so the log was the thing that looked broken.
It asks the schema builder now, which is what Permissions::tableExists() already did — and
its comment already explained why, one caller over:
hasTable() also resolves #PREFIX# and the schema-versus-prefix difference between drivers,
which the raw table name did not.
The test is a PostgreSQL integration test, because this is a dialect fact: MySQL's failed select writes no such line, so the bug is invisible there and a mocked connection cannot report it at all. There are two assertions — that the probe logs nothing, and that the discarded form did. The second is what stops the first passing for the wrong reason.
Every log entry was dated the moment you looked at it¶
strtotime('28/08/2026 13:39:37') returns false. It reads a slash-separated date as
American month-first, and there is no month 28.
That string is what the framework's own Logger writes — day first, as it renders dates
everywhere else. Both log readers parsed it with strtotime(), got false, and fell back to
time(). So every entry the framework had ever logged came back stamped with the moment
somebody opened the screen.
The failure was never a missing date. It was a plausible wrong one, which is the only kind nobody checks:
- an error from three days ago read as having just happened;
- the trend chart put the whole file in the current bucket;
- and "3 errors in the last hour" meant three errors from any hour there had ever been, because an entry dated now passes every window check.
It was found by using the new log-errors tool on this installation, seeing an error that had
been fixed an hour earlier reported as current, and going to look for the caller that was
still producing it. There wasn't one.
LogManager::parseTimestamp() is the single parser now — the framework's own day-first
formats first, then ISO and everything else PHP knows, because those are unambiguous and
guessing is only safe there. 08/02/2026 is 8 February, the way the writer of the line meant
it, not 2 August.
And a date that cannot be read returns null rather than a guess. A null timestamp is in
no time window: an undatable entry counted inside whichever window was asked for is how a
whole log file ends up in the last hour's figures.
mcp:serve is not something a person could debug¶
It speaks JSON-RPC on stdio and blocks on STDIN. Run by hand it looks like a hang. Run by a
client — Claude Code, an IDE — the client owns both pipes: STDOUT is the protocol and STDERR
goes wherever the client puts it. There was no way to see what a tool returned, and
hand-writing an initialize frame to find out has the worst property a debugging procedure
can have: a mistake in the frame is indistinguishable from a broken tool.
Two additions, for the two different questions.
mcp:call — what does this tool return?
php <cli> mcp:call # every tool, with the arguments it takes
php <cli> mcp:call log-analytics --arg timespan=6h
php <cli> mcp:call log-errors --json '{"limit": 5}'
php <cli> mcp:call route-list --raw # the envelope, unwrapped
It dispatches through McpServer::dispatch() rather than reaching for the tool object,
because a tool that works when called directly and fails through the protocol is a real bug
and that is where it shows. --arg amount=2 arrives as the number 2 — a shell has only
strings, and a schema wanting an integer would otherwise reject the obvious spelling. And a
tool that threw exits non-zero: an exception comes back as a successful JSON-RPC
response whose content is the exception message, so without that it prints like an answer.
mcp:serve --log — what is the client actually sending?
Every message, both directions, written in the framework's own structured-log format. So the
log viewer, LogAnalytics and the log-errors tool all read the file with no idea it is MCP,
and the useful query is one that already existed:
A failed call is logged at error — including the thrown-tool case the protocol reports as a
success, which would otherwise be filed as routine and be unfindable among a thousand good
calls. A malformed line is logged with the input that caused it, because "Parse error" alone
is the least actionable message a protocol can produce. It is off unless asked for, says its
path on STDERR when it is on, and an unwritable path degrades to no logging rather than taking
the server down with it.
An MCP tab in the DevPanel: the schema as a form, the answer on the page¶
mcp:call made the MCP server inspectable from a terminal. This is the same question asked
from inside the panel, and it adds the part a terminal cannot do conveniently: /devpanel/mcp
renders each tool's input schema as a form, so its arguments are discovered instead of
looked up.
Tools
▸ log-analytics
Summarise this installation's logs: entry trend, counts per level, …
timespan [1h ▾] files [comma separated]
[ Call ] ☐ show the JSON-RPC envelope 41 ms
// sent {"timespan":"1h"}
{ "trends": { … }, "levels": { … } }
Four decisions in there, each one a wrong answer avoided:
- Every field can be left out. An omitted argument and an empty string are different — a
tool with a default gets to keep it — so each control carries an explicit
— omit —. A boolean is a tri-state select rather than a checkbox for the same reason: an unchecked box cannot say "leave it out", andfalseis not the same request as silence. - What was actually sent is printed above the answer.
{"limit": "5"}and{"limit": 5}are different calls. A schema that rejected the first is otherwise a mystery, and the browser is the thing that decided which one to build. - A tool that threw is shown as a failure. MCP reports it as a successful response whose content happens to be the exception message. Without saying so, the page prints an exception as the result.
- The call goes through
McpServer::dispatch(). A tool that works when invoked directly and fails through the protocol is a real bug; the envelope checkbox is where it shows.
The tab builds its own server when the container has none, so it works with the mcp feature
switched off — and says so accurately. The tempting warning is "MCP is off, no client is being
served", and it would be false: mcp:serve serves the built-in tools either way. What the
feature actually adds is the container binding that an application's own tools get
registered into, so that is what the notice says.
The POST that runs a tool carries a CSRF token. The panel's other endpoints read; this one executes whatever a project registered, and a project is free to register a tool that writes.
And a third copy of a list, removed on the way: the tab names lived in renderLayout() and
in tabStrip() — the one other pages borrow — and the comment on the second declared itself
the single source while being the copy. Adding a tab meant adding it twice, and forgetting the
second gives a page wearing a strip it does not appear in. tabs() is the list now. The
dispatchable-actions list stays separate, because it genuinely means something else — adminer
is a tab and not an action, overview is a tab whose action is display, logs is an action
with no tab — and a test asserts they agree where they should.
find-symbol: the question grep cannot answer¶
Grep finds strings. The question is almost always about calls — and, more precisely, about which function each call sits inside.
This one was written from a failure earlier the same day. Tracing which code ran
SELECT EXISTS(SELECT 1 FROM permissions) took eight greps and then a patch to
QueryBuilder::exists() that dumped a backtrace to a file, in a framework whose entire source
was sitting on disk. Grep could not find the caller because the calling line contains neither
word being searched for — it reads $database->queryBuilder()->table($table), and the name was
in a constant three lines above.
{
"files": {"searched": 1550, "containing": 8},
"definitions": [{"kind": "method", "name": "Pramnos\\Database\\SchemaBuilder::hasTable",
"file": "…/SchemaBuilder.php", "line": 308}],
"callers": [{"in": "Pramnos\\Auth\\Permissions::tableExists", "line": 413, "type": "method",
"code": "return $database->schema()->hasTable($table);"}],
"counts": {"definitions": 1, "callers": 7}, "complete": true
}
in is the field that earns the tool. Permissions::tableExists explains a line number;
src/Pramnos/Auth/Permissions.php:413 does not.
Token-based, so a name inside a comment, a doc-block or a string is not a call. Measured on one
real case: grep returned 14 hits for parseTimestamp — four calls, ten in tests, and one
sentence in a comment. It also reports how many files it searched, not only what it found,
because "no callers" is trustworthy only next to that number.
Three decisions in it:
- The framework's own tests are in scope. "A test asserts this" is part of the answer to "who calls this", and it is the part that says whether changing it is safe.
Foo::with no parentheses is a use ofFoo. The parentheses belong to the method, and without this "who uses this class" came back empty for every class only ever reached statically — which in this framework is most of them.- Naming a class narrows the definitions and not the callers, and the answer says why:
deciding that a particular
$thing->hasTable()is aSchemaBuilderwould need type inference. An empty answer likewise names what the tool cannot see — a dynamic$method(), acall_user_func— because "nothing calls this" is how a method gets deleted.
No cache. The plan said to cache the token index; measuring first said otherwise — tokenising all 557 framework files takes 60ms, and a cache would be a second source of truth able to go stale about the one thing this tool exists to be right about.
Two bugs in the first draft, both found by pointing it at this repository rather than at its
fixtures. "{$user->name}" opens with a T_CURLY_OPEN token and closes with a plain }
character, so a class containing one interpolated string appeared to close a brace it had never
opened: the scope stack unwound and every later call was attributed to a bare function instead
of a method. And a free function was reported unqualified, so two loose() in different
namespaces read as one. A wrong in is worse than a missing one — it sends the reader to the
wrong class.
The DevPanel's MCP tab shipped with a JavaScript syntax error¶
Uncaught SyntaxError: Invalid or unexpected token, reported from a browser within the hour.
The script lives in a PHP heredoc, where \n is an escape PHP consumes itself. So '\n'
written in the source reached the browser as a real line break inside a JS string literal:
Every test on that panel passed. The markup was right, the CSRF token was right, and
assertStringContainsString('// sent ') matched happily — because nothing was asserting that
the output was a program. A test can confirm every substring of a script and still not notice
it does not parse.
It now goes through node --check, which is the only thing that actually knows. The precedent
was already in the repository: the human check's client-side solver is executed under Node by
its own test, for the same reason — shipped JavaScript that is only ever asserted as text is
JavaScript nobody has run.
A second, narrower test asserts the escape survives as an escape, because node --check would
also pass on a script that lost the newline in some other still-parseable way, and joining log
lines with a real \n is the behaviour rather than an implementation detail.
route-list executed the views, and then said there were no routes¶
Two failures in one tool, both reported from a browser.
It printed a page of HTML into the response. RouteDiscovery::discover() found attribute
routes by require_once-ing every .php file under a directory, which is only safe if each one
declares a class and does nothing else. Pointed at a namespace root — App\ => src/,
which is how the project's own composer.json maps it — it swept in src/Views/**/*.html.php
and ran the templates. $this inside a view was bound to the discovery object, so the
template set dynamic properties on it and then called a method it does not have.
From the MCP server that is worse than a broken page. The stdio transport is STDOUT, so a view printing into it corrupts the JSON-RPC stream and the client reports the whole server as broken.
Discovery now looks before it requires: a token scan says whether the file declares a class,
which costs microseconds and cannot run anything. And McpServer captures whatever a tool
prints instead of letting it reach the stream — the root cause is fixed where it belongs, but
the next tool to print will be a different tool, and a stray var_dump, a deprecation notice
or a driver warning must not be able to break the protocol. Captured, not discarded: the output
comes back as a second content block saying how many bytes were swallowed, because hiding it
would hide the thing somebody needs to see.
Then it answered "No routes found" on an application with fifty of them, with a note
explaining that including a routes file would serve a request rather than describe one. The
note was true — this project's file ends in return $router->dispatch($request) — and the
answer was still useless, because it reads as a fact about the application.
The routes are statically readable. $r->get('/me', …) is a literal method name and a literal
string, and ->group(['prefix' => '/admin'], function () { … }) nests them, so they are parsed
rather than run — the same trade that fixed discovery in the paragraph above:
{"method": "GET", "uri": "/session/heartbeat", "action": "(closure) Session@heartbeat",
"source": "routes-file", "file": "src/Api/routes.php", "line": 33}
(closure) on its own says nothing, and closures are how this codebase writes every API route
— so the controller call inside is dug out, which is the part anybody is looking for.
Three near-misses on the way, all the same species: an answer that looks precise and is wrong.
- The first prefix reader accepted the leading literal of a concatenation, so
'prefix' => '/' . (defined('APIVERSION') ? APIVERSION : '1.0')came back as/and every route in the group was reported at//me. A prefix that cannot be resolved without running the file is now returned as the expression it is, in braces. Somebody would have called//me. - The prefix was searched for in the whole argument list, which for
group()includes the entire closure — hundreds of lines of other routes. It is argument zero and nothing else. - Comments are tokens with text, and the text was being concatenated and split on commas — so a comment containing a comma became argument zero. This project's routes file has exactly such a comment, which is how it was found.
A link in the DevPanel is styled wherever it is¶
The rule covered table.data-table a, because that is where the first link went. The next one
went into an info table — "readable in the log viewer" — and arrived as the browser's default:
a visited link, in a colour nobody can read on #313244, next to a green badge.
Fixing the instance instead of the class means fixing it again every time a link is added, so the selector is the panel's content area now. Info-table labels also stopped taking a fixed 40% of the row: with short labels and one-word values the pair read as two unrelated columns.
Two more MCP tools: what the CLI can do, and what the theme is made of¶
console-commands. Seventy-odd commands, twenty of which generate code — create:crud,
create:screen, create:api-client, create:policy, create:webhook. The reason this is a
tool is the failure it fixes, observed from the inside: an assistant working in this codebase
for a whole day writes a controller by hand rather than running create:controller, because
nothing told it the command exists. --help on seventy commands is not a discovery mechanism.
{"name": "create:crud"}
→ {"usage": "create:crud [name]", "generates": true,
"options": [{"name": "--table", "shortcut": "-t", …}],
"class": "Pramnos\\Console\\Commands\\Make\\MakeCrud"}
generates marks the commands that write files, which is not something to infer from a
description — it decides whether a command can be run to see what it does. class is there so
find-symbol is the obvious next question. And the list is read from the live console
definition, because a catalogue kept in the tool is a second thing to forget, and this exact
file has already been bitten by that.
theme-info. The palette, the themes, the theme directories — and whether the compiled
stylesheet was built from any of it.
That last part is the reason. daisyUI is a Tailwind plugin, so it cannot come from a CDN and
the build is not optional: a project that edits app.css without rebuilding serves a
stylesheet in which its component classes resolve to nothing, and the page renders unstyled
rather than failing. The compiled file is committed on purpose, so a checkout serves the site
without npm — which makes it precisely the artifact somebody forgets to regenerate.
"freshness": {"built": true, "built_at": "27/08/2026 14:51", "stale": true,
"newer_than_the_build": ["src/Views/register/register.html.php", "…"]}
Its first run on a real project found exactly that: a stylesheet built the previous afternoon and eleven files changed since, including two views edited that same evening.
Three kinds of source are checked, because Tailwind depends on all three — the entry
stylesheet, the palette it @imports, and every directory it @sources for class names. The
third surprises people: adding btn-primary to a template means that class has to be
generated, so an untouched app.css proves nothing. Paths come out of the package.json
script rather than being assumed, the --watch script is never offered as the build command,
and a project with no Tailwind script is pointed at theme:build, whose output has no
freshness problem because it is a direct translation of the palette.
Three tests that were a copy of the tool catalogue¶
Adding a tool broke the same three assertions three times in one day: a literal 10 tools in
the banner, and two hard-coded lists of every registered tool. Each break taught nothing except
that the number had changed.
They assert properties now. The banner test reads the count and the names from the server and checks every registered tool is announced — a tool that is registered and not named is a tool an assistant has no reason to believe exists, which is a real defect the old test could not see. The no-application test asserts that what remains needs no application and that the five which introspect this application are absent. The fallback test asserts containment rather than an exact set, because the thing that actually guards the catalogue is the separate test comparing the command's list against the provider's — and that one has never needed touching.
A test that has to be edited every time the thing it watches grows is not watching it.
api-docs and find-tests: the other two of the four¶
api-docs. route-list answers what URIs exist; this answers what the API promises —
parameters, request bodies, response codes, which credential each operation needs. That is the
shape an integration is written against, and it was reachable only by opening a JSON file and
reading it.
The freshness half is the stylesheet problem again, and worse in one way. The OpenAPI document is a generated file that gets committed, so a controller can gain a parameter while the published document goes on describing the old shape. Nothing fails: the API works and the documentation lies, which is the worst available outcome because somebody believes it. First run on a real project: a document generated on the 25th, a controller changed on the 26th.
Two generators are recognised — api:docs reading #[Route] attributes, and an
openapi:generate npm script converting apiDoc annotations. Reporting only the framework's own
would tell half the projects they have no API documentation. An application with neither is told
that api:docs would produce an empty document, and pointed at route-list.
One bug found by looking at the output: servers, parameters and $ref are legal keys beside
the methods under a path. Treating every key as an operation invented SERVERS /oauth/token and
inflated the count from 15 to 20 — a fabricated endpoint in a list of endpoints is the same
failure as a wrong URI, and somebody would have tried to call it.
find-tests. Where the test for this is, from #[CoversClass] rather than from a guessed
filename — because guessing has a wrong answer often enough to matter. Pramnos\Logs\LogManager
is tested in tests/Unit/Pramnos/Logs/, not tests/Unit/Logs/; that exact mistake was made
earlier the same day, and a heredoc write into a directory that does not exist fails silently.
It runs nothing. Running tests is something a shell does well, and wrapping it would hide
the project's rule about how — these projects hold a lock, and two concurrent runs corrupt the
shared test databases. So it reports the command, ./dockertest when the project has one, as a
--filter alternation of every matching test class: naming one of three looks like the
command that verifies a change and silently skips two thirds of the evidence.
And an undeclared class is not called untested. #[CoversClass] is a declaration, not a
measurement — but it is what the coverage report goes by, so the answer says both and points at
the test files that merely mention the class. That found a real gap here on its first run: a
SeoTest exercising Seo without declaring it, which is a gap in the test rather than in the
coverage. It has the attribute now.
Three bugs in these two, all found by pointing them at this repository rather than at fixtures:
- A
staticinside a method is shared by every instance. The test-file listing was memoised that way, so a second call with a different root got the first call's answer. Wrong in a test, and wrong in production too —mcp:serveis long-lived, so a test file added during a session would never have been seen. #[\PHPUnit\Framework\Attributes\CoversClass(...)]written out is the same declaration as the short form with auseat the top. Matching only the short one reported half a codebase as undeclared, which is the direction of error that gets code deleted.- A line-anchored regex misses a single-line file.
<?php namespace App; class Thing {}declared nothing as far as the class detector was concerned. It reads tokens now, which also means aclassinside a comment is not a declaration.
Plus one shared with the earlier tools: a tests tree is not stable while tests run. The suite
creates and deletes fixture directories, so a subdirectory listed a moment ago can be gone by the
time the iterator opens it — which threw Failed to open directory: tests/Fixtures/… and took
the whole answer with it. CATCH_GET_CHILD, in both this and find-symbol.
Two rules that could not be checked, and now can¶
Both were written down, both were being ignored, and both for the same reason: the output was dominated by facts older than the change being made. A gate whose baseline is noise is not a gate.
pramnos-check --since. Run over src/, that tool reports 76 findings — nine raw-SQL and
sixty-seven flash-query-parameter ones. Its own guide says so. With 76 pre-existing findings
there is no way to see your own three, so nobody ran it, including the assistant instructed to
run it before calling a change finished.
{"since": "HEAD", "changed_files": 4, "changed_lines": 212,
"suppressed": 5, "findings": [], "verdict": "No findings on the lines you changed."}
HEAD is everything uncommitted; staged is the index, for a pre-commit gate; any ref works. A
new file counts entirely, because that is where new violations live. Editing one line of a legacy
file does not surface its other findings — otherwise this would be a file-level filter with a
misleading name. And outside a git working tree it refuses rather than reporting a clean change:
"no findings on the lines you changed" when nothing was compared is a pass nobody earned.
coverage. The rule is above 95% on changed code, and a coverage run produces a project-wide
percentage — which barely moves when fifty uncovered lines are added to twenty thousand covered
ones. So the rule was satisfied by assumption. Two thousand lines were written in a day without
it being checked once.
The new tool intersects the clover report with the diff and answers in line numbers, which is short enough to act on. Pointed at its own author's work the first time, it said 7.5% — and that is the whole point: a number that can fail.
Four things it is careful about:
- It runs nothing.
./dockertest --coverageholds a lock, and could not run from inside the container it would have to start. It readscoverage/clover.xml, which the scaffolded test script now writes beside the HTML report — the HTML is for a person, the XML is for a tool. - A report older than the code is called stale. Reading a stale one is worse than reading none: it reports the previous version of a file as covered, with the line numbers moved underneath it. Observed immediately — the first run described a file edited ten minutes earlier.
- Unmeasurable lines are not counted against you. Blank lines, closing braces, comments and property declarations are absent from clover entirely, and counting them as uncovered would turn every honest change into a failure.
- A container's paths are joined to project-relative ones. Clover records what the test run
saw —
/var/www/html/src/…— and getting that join wrong reports every line as unmeasurable, which is a silent pass.
One more thing the tool said about itself: at 99% the verdict still read "the rule in these projects is above 95%", which sounds like a failure. A tool that cries wolf gets its next reading ignored, so passing now reads as passing. The threshold is stated either way; which side of it you are on is the part that has to be unambiguous.
Both share Pramnos\Support\GitChanges, which answers one question — which lines changed — in
the shape a per-line filter needs. It passes safe.directory per invocation rather than writing
anything: inside a container the files belong to the host user and git refuses with "detected
dubious ownership", which is the correct default and not ours to change globally.
changelog-add: the one tool that writes¶
The only tool here that writes, and it earned that by being a ritual that kept going wrong.
The rule is one post per day, with every section listed at the top under a count. Adding an entry
therefore means three things: append the section, rebuild the list from the headings, and get the
count and its plural right. Done by hand a dozen times in a single day, that produced a regex that
threw on \D and a summary list left three entries behind the sections it was supposed to
summarise. Nothing noticed either time — the page renders, and the list is simply wrong.
Mechanical, repetitive, and silent when it fails. That is the whole case for a tool.
{"title": "Two rules that could not be checked", "body": "…", "categories": ["Testing"]}
{"title": "…", "body": "…", "preview": true} // see the result, write nothing
Four things it refuses to do:
- It never edits the summary list. The list is derived from the
##headings, every time. A hand-maintained list drifts from what it describes, and the drift is invisible. - It refuses a duplicate title unless asked to replace. Two sections with one name is a summary entry pointing at whichever the reader finds first.
- It verifies before writing. If the rebuilt list does not have exactly one entry per section, nothing is written and it says so — because that mismatch is the failure it exists to prevent, and producing it silently would be worse than the hand-editing.
- It will not write into an installed package. A changelog entry under
vendor/is edited into oblivion by the nextcomposer update, and belongs in the framework's own history. A development checkout — a symlink, or a git tree — is the framework's own history, so that counts.
A new post gets the frontmatter, the # 28 August 2026 heading, and the <!-- more --> fold in
the right place: everything above the fold is the excerpt the blog index shows, so the summary
belongs above it and the sections below. A post without the fold prints itself in full on the
index page.
This entry was written by the tool.
The plain-text part of an email was the CSS, with the links removed¶
Every message goes out as multipart/alternative, and the text half was strip_tags($body) —
which produces a part that is technically present and practically useless. Three specific
failures, and each one is worse than it sounds:
- Every link disappeared.
strip_tagskeeps the anchor text and throws thehrefaway, so «click here to confirm your address» arrived with nothing to click and no address to copy. On a confirmation mail that is the entire message gone. - The text ran together. HTML mail is nested tables and adjacent cells have no whitespace
between them, so a header, a heading and a paragraph arrived as one line:
OneTwoThree. - The stylesheet came along.
strip_tagsremoves the<style>tags and keeps what was between them, so the first thing a reader in a text-only client saw was.x{color:red}, followed by the subject line repeated out of<title>.
And a text part that does not match the HTML is a documented spam signal — so the half of the message that exists to help deliverability was hurting it.
Pramnos\Email\PlainText converts, using DOMDocument, with no new dependency: an
html-to-text package is a reasonable choice for an application and the wrong one for a framework,
which would impose it on every project that ever sends a message.
Example <https://example.com>
Confirm your address
Hello Yannis, please click here to confirm
<https://example.com/confirm?t=abc123>.
- One
- Two
Device | Last seen
Chrome | 28/08/2026
The one decision worth singling out: a layout table and a data table are read differently,
and the marker used to tell them apart is role="presentation" — which the framework's own mail
wrapper already sets, for screen readers. A layout table's cells joined with | is nonsense; a
data table's rows joined with newlines is unreadable. The distinction was already written into the
markup for a different reason, so it did not have to be guessed.
The rest, briefly: display:none and zero-height elements are skipped, because a preheader is
written for the inbox preview and is invisible in the HTML — repeating it in the text is a
difference between the two halves. An image contributes [alt] or nothing, since a line reading
[] is worse than no line. Lines wrap at 78 columns and a URL is never broken, because a wrapped
URL is an unusable one: the client links the first half and leaves the rest as text.
And the charset is declared with a <meta> prepended to the markup rather than with
mb_convert_encoding($html, 'HTML-ENTITIES', …), which is deprecated as of PHP 8.2. Without
either, libxml assumes ISO-8859-1 and every Greek character in the message becomes mojibake.
Four headers that decide what happens to a message¶
Four headers, added automatically. None of them changes what the reader sees, and all of them
change what happens to the message — which is exactly why they go missing: nothing in mail
reports a missing header back to the sender, and nothing reports a malformed one either. That is
how List-Unsubscribe went out without its angle brackets for a long time, looking correct in a
dump and being ignored by every provider.
| Header | On | Why |
|---|---|---|
Auto-Submitted: auto-generated |
every message | RFC 3834. Stops an out-of-office responder replying to a password reset — and then to the reply, which is how a mail loop starts |
X-Entity-Ref-ID |
every message | Gmail groups by subject, and «a new sign-in to your account» repeats |
Precedence: bulk |
list mail | The older half of the same idea, honoured nearly everywhere |
List-ID (RFC 2919) |
list mail | A stable identifier, so a client can group and filter by list |
Feedback-ID |
list mail | Google Postmaster's grouping key |
The X-Entity-Ref-ID one is worth spelling out, because it is a real defect being fixed rather
than a box being ticked. Gmail collapses messages that share a subject, and this framework's most
useful mail has a repeating subject. Two sign-ins from two different devices arrived as one
message, with the older one hidden behind "show trimmed content" — which is precisely the message
somebody needs to see twice.
Feedback-ID is the other one that earns its place: without it every spam complaint about every
message lands in one bucket, so Postmaster Tools can tell you that something is wrong and not
what. With it the rate is broken down by the first field, and «the newsletter is being marked as
spam and the receipts are not» becomes a fact rather than a theory.
Three things they are careful about:
- A header you set yourself wins. These are defaults, not policy. An application with its own
Feedback-IDscheme distinguishing campaigns — the whole point of that header — must not have it overwritten by a generic one. Precedence: bulknever goes on transactional mail. Marking a password reset as bulk invites a provider to deprioritise the one message the reader is actually waiting for.- With no host to be found,
List-IDandFeedback-IDare left out rather than invented. A stable identifier for a domain you do not send from is worse than no identifier. And a list name is reduced to the characters the headers allow, because one bad character or one over-long field invalidates the whole header — silently, and nowhere near the sender.
Gmail actions: a button in the message list, and the reason yours is not showing¶
Gmail looks for application/ld+json in a message and, when it finds a block it recognises, draws
a control in the message list — a "Confirm" button beside the subject, before the message is
opened. That is the difference between a confirmation that takes one tap and one that takes four,
and the markup for it is nested JSON nobody writes correctly from memory.
$mail->addStructuredData(Actions::confirm(
'Confirm address',
'https://example.com/confirm/abc123'
));
Actions builds the shapes: confirm, view, save, rsvp, plus sender for the brand mark
beside the subject and promotion for a Promotions-tab card. Email::addStructuredData()
collects them and puts them in the <head>, where Gmail's documentation puts them and where a
<script> cannot disturb the layout.
Encoded through Html\Seo::jsonLd() rather than json_encode(), which is not a stylistic
preference: a </script> inside any value ends the block early and everything after it is parsed
as markup, and these values come from record titles and user input. There is a test that a
</script> in an action's name cannot end the block.
Three decisions worth stating.
A builder with nothing to describe returns nothing. Actions::rsvp([]) is [], not an
EmailMessage with an empty action list, and addStructuredData([]) is ignored — so a message
that uses none of this is byte-for-byte what it was. A <script> containing [] is a claim that
the message has no actions, which is a different statement from making no claim. Same rule the
JSON-LD encoder documents, one level up.
The requirements are returned as data, not only written in a guide. Actions::requirements()
lists them, because the failure mode is somebody concluding the code is broken: Gmail displays
none of this until the sending domain is registered with Google, and that fact is invisible
from anywhere inside an application. Everything stays correct and harmless without registration —
other clients ignore what they do not understand, and the markup is invisible in the rendered
message.
The framework wires none of its own mail to an action. Not an omission. A ConfirmAction
handler must act on the first request, with no confirmation page and no sign-in — Gmail sends a
POST and does not follow up — and no endpoint the framework ships satisfies that: the verification
and step-up routes need a session and render a page. An action pointing at one of them would draw
a button that does nothing, and a dead button is worse than no button.
A ViewAction never needed a handler — the password-reset mail has one now¶
The entry above said the framework wires none of its own mail to an action, because no endpoint
satisfies the ConfirmAction contract. The first half of that was too broad, and it was pointed
out within the hour.
A ViewAction has no such contract. It is a URL — no POST, no first-request promise, nothing
to receive anything. "We have no handler for it" was never a reason not to use one; the constraint
belongs to ConfirmAction alone, and it got applied to every action because they arrived in the
same commit.
So the password-reset mail carries one now. That mail contains exactly one link, which is its entire purpose, so an action pointing at it exposes nothing the message did not already expose — and turns four taps on a phone into one.
The other two remain unwired, for two different reasons that are worth separating:
No ConfirmAction, because the handler does not exist yet. The contract is real: the URL must
act on the first request, with no confirmation page and no sign-in, because Gmail sends a POST and
does not follow up. The verification and step-up routes need a session and render a page. Wiring
one means building the handler, which is a reasonable thing to do and not something to bolt onto
an existing route.
No action on the new-sign-in alert, because that is a decision. That message contains no link either, and its own docblock says why: a link in an unexpected security email is the shape of the attack it warns about. A button in the message list is the same thing, larger and easier to press. A one-tap "this wasn't me" would need both a new one-click revoke endpoint and a reversal of that judgement.
Both are now asserted rather than described. There is a test that a ViewAction carries no
handler — the distinction that was got wrong — and a test that the new-sign-in notification
mentions neither Actions:: nor addStructuredData, because that decision reads like an omission
and the next person to add "one-tap review your sessions" will be improving the product.
One-click mail actions, and the handler a "this wasn't me" button needs¶
"No handler exists" is a reason to build the handler. Pramnos\Email\MailAction is that handler,
generalised from the one endpoint the framework already had of exactly this shape — RFC 8058
one-click unsubscribe — so an application adds its own in three lines instead of writing a
controller, a token format and a signature check.
MailAction::register('confirm-order', fn (array $c): bool => (new Order((int) $c['order']))->confirm());
$url = MailAction::url('confirm-order', ['order' => 42], 172800);
$mail->addStructuredData(Actions::confirm('Confirm order', $url));
/mailaction is a bundled controller, so there is no route to register.
The token is the whole authorisation. No session, no CSRF token — the caller is a mailbox provider's server and neither exists. That is not new (a password-reset link has always worked this way) but it decides everything else. The token is signed; it expires, with the expiry inside the signed material, because an expiry beside the signature is one the holder can edit; it names one action and one payload, and the payload is readable by whoever holds it, so it carries identifiers and never secrets.
verify() answers null for a forgery, a malformed token and an expired one alike — distinguishing
them would tell somebody probing how close they are. expired() exists separately so a page can
say "this link has expired, ask for another", which is useful and safe to say.
A GET performs nothing by default, and that is not ceremony. A GET is issued by things nobody
asked for: a link scanner in a corporate mail gateway, a client prefetching to build a preview, an
antivirus proxy. If a GET acted, those would act — so a person following the visible link is shown
a page with a button, and Gmail's POST works either way. An action whose effect is safe that way
opts in with actOnGet: true; confirming an address is the clear case, since whoever holds the
message has already proved the point.
The handler must be idempotent. Gmail retries, a reader may press twice. Confirming an
already-confirmed thing is a success — returning false there turns a second press into a 500 and,
on a provider that retries 500s, into a loop. false and a thrown exception both become a 500,
which is correct: the usual cause is a database that was briefly away, and that is what retrying
fixes.
One status code is worth singling out. A valid token for an action nothing handles answers 501, naming the action, because that is almost always a handler registered in a service provider that did not run — a feature switched off, a provider removed. "Not valid" would send somebody to inspect the token instead of the registration.
What ships registered: revoke-sessions, the handler a "this wasn't me" button needs. It ends
every session on the account. The framework does not put it in a message — the only message it
would belong in is the new-sign-in alert, which deliberately carries no link at all — so the
capability is here and the judgement is left where it belongs.
The unsubscribe page was 181 KB, and 180 of them were the website¶
Found by building the second endpoint of this shape beside the first. /unsubscribe was returning
its own self-contained page followed by the entire site layout: 181 KB where 1.1 KB was
intended.
A controller that echoes and returns leaves the framework to render the page afterwards. So the
plain-text line a mailbox provider reads was followed by a full HTML document — under a
Content-Type: text/plain header — and the page a person saw was followed by the site's header,
navigation and footer, after its own </html>.
Nothing failed. The status codes were right, the words were right, the unsubscribe worked; the
response was simply 180 times larger than the answer and structurally nonsense. The fix is one
call, to a document type the framework already has for exactly this — raw, the one the log-viewer
iframe uses — which renders the body and nothing around it.
It is asserted on the source of both controllers rather than by rendering, because the defect happens after the controller returns, outside anything a unit test drives, and nothing about the visible output revealed it until somebody measured a response in kilobytes.
A session count that was not a number, and four tables called sessions¶
Two defects, both found by writing tests that assert a value rather than an effect.
User::revokeOtherSessions() returned nonsense. Its documented return is "how many session
rows were ended", and the line was $ended = (int) $sessions->update([...]) — but update()
returns a Result object. PHP raised "Object of class Result could not be converted to int" and
produced a number that means nothing.
Nothing broke visibly, which is why it survived: the sessions were ended correctly, and only the
count was wrong. It reads getAffectedRows() now, and there is a test that asserts the count is
3 for three sessions and 0 the second time.
Four fixtures each declared a different sessions table. All with
CREATE TABLE IF NOT EXISTS, so whichever test ran first defined the shape for the entire run:
one had no visitorid, one had no sid, one keyed on visitorid, and one invented
sessionid/time/guest for a table whose real schema has eleven columns.
The consequence is a test suite where a fixture written against any one of them passes alone and
fails in the suite — which happened twice while writing the integration test for revoke-sessions,
each time with a different missing column.
Two things done about it. The dashboard fixture that invented sessionid now declares the
framework's real columns, since the service it tests reads only guest and time and never needed
the invented shape. And the new integration test builds its row from the intersection of what
revokeOtherSessions() needs and what the table actually has, skipping with a stated reason if the
columns it needs are absent — because a test that depends on which other suite ran first is not
testing anything.
The four-way disagreement is worth fixing properly, in one sweep, with each fixture pointed at the real migration. It is not this change.
Email tracking that works, and only for mail somebody agreed to receive¶
Email::enableTracking() has been in the framework for years and has never worked. No migration
created the table it wrote to, so the insert failed into a catch. No route served the pixel it
embedded, so it pointed at a 404. Nothing was ever recorded, anywhere.
What it did do was append a remote image to the message body the moment it was called — whatever the message was, whatever the installation's policy, whoever the recipient. It managed to measure nothing while doing the one thing that needs consent.
Off unless three things are true¶
'email' => ['tracking' => true]inapp.php. Absent means off.- The message belongs to a list —
offerUnsubscribe()was called on it. enableTracking()was called on that message.
Gate 2 is the one that matters: a pixel in a password reset is a remote image in the most sensitive message a system sends, to somebody who agreed to nothing.
The id is still generated when enableTracking() is called, because an application may store it
beside its own record and the gates should not cost anybody that. What changed is that nothing goes
into the message until it is sent, when it is known whether tracking applies.
An open is a weak signal, and the numbers say so¶
Apple Mail Privacy Protection — on by default since iOS 15 — fetches every remote image through Apple's proxy the moment a message arrives, whether or not anybody opens it. Gmail proxies and caches, so the fetch is Google's and later opens may never arrive. Plenty of clients block remote images entirely, so a real open records nothing.
So opens and proxy_opens are separate columns and are never added together. A single "opened"
figure is how a message nobody read is reported at a 70% open rate. Apple's fetches identify as
Safari, so the user agent alone cannot name them and the two networks that fetch on delivery are
matched as well — and a proxy that stops identifying itself will be counted as a reader, which is
the honest limit of the method and is written down as such.
A click is a person. No proxy follows a link. It is the number worth reading, and it is new:
Tracking::wrapLinks() rewrites every http(s) link so following it is recorded, leaving alone
mailto:, anchors, and the unsubscribe link — a reader unsubscribing is exercising a right,
and routing that through a tracker is both distasteful and a way to break the one link a mailbox
provider tests.
The destination lives inside the signed token, never in the URL. A tracker that reads its destination from a query parameter is an open redirect, and an open redirect on a domain that sends mail is a phishing kit somebody else gets to use: the link comes from your domain, in a message that looks like yours, and lands wherever the attacker chose.
What ships¶
A migration for emailtracking and emailtrackingclicks; bundled /emailpixel and /emailclick
routes, so an application has nothing to register; and an Opens column on /admin/emails,
where prefetches are shown apart from opens and an untracked message shows a dash — the honest
rendering of "nobody measured this".
One deliberate change of behaviour¶
enableTracking() no longer appends the pixel when called. Three existing tests asserted that it
did, and they were pinning the defect: the eager pixel is exactly what put a remote image into
messages nobody had consented to. The tests now assert the correction and say why. Nothing else
about the method's contract moved — the id is still there, it still chains, and it still cannot
throw.