Skip to content

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/Services says 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
  • Session can write, not only read
  • One apostrophe no longer destroys a page's breadcrumb structured data
  • Html\Date renders the time and the dropdowns it always claimed to accept
  • The queue worker runs under a supervisor
  • /admin/Services can 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\Date reads 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 Seo and then never mentioned it again
  • mcp:serve had 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:serve is 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-list executed 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-docs and find-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. The cache() action enumerates every category and reads up to fifty items from each; the test's own tearDown() 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-memory ArrayAdapter, 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 full cacheflush() per test is cheap on an empty cache and expensive on a hot one. Flushing userlist, the category User::load() actually reads through, is 0.68s in either case.

  • TokenActionMySQLTest, 7.7s. The same full flush, for Token::load()'s 3600-second cache. usertokens instead: 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.png is not logout, 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

'devpanel' => ['mount' => 'devpanel', 'min_usertype' => 90],

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, a DaemonOrchestrator subclass named daemons:start, declaring the queue worker when the queue is enabled;
  • its registration in the application's Console;
  • a daemons service in docker-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.

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.

$mail->offerUnsubscribe('newsletter');   // after `to` is set

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

composer require vrana/adminer

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 the include never ran and the asset-URL rewrite was skipped. PHP flushed the buffer at shutdown with Adminer's own ./static/default.css links in it, which resolve to nothing under /adminer. The rewrite is a buffer callback now, which exit cannot 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 is rand() ^ $_SESSION["token"]; ours is a hex string. Our session is closed before Adminer sees the request, so it gets the adminer_sid namespace 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 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:

  1. the date_format / datetime_format / time_format settings — a site-wide override;
  2. app.php'dates' => ['el' => ['date' => 'j/n/Y']], per language, versioned with the code;
  3. 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.

$hasCharts = \Pramnos\Framework\Factory::getDocument()->hasAsset('chartjs');

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:

$analytics = \Pramnos\Logs\LogAnalytics::summary($timespan, $this->whitelist);

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:

  • topErrors is 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.
  • truncated is 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:

$this->assertSame($names($fromProvider), $names($fromCommand));

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:

$exists = $database->schema()->hasTable((string) $table);

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:

php <cli> mcp:call log-errors --json '{"files": ["mcp.log"]}'

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", and false is 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.

{"name": "hasTable"}
{
  "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 of Foo. 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 a SchemaBuilder would need type inference. An empty answer likewise names what the tool cannot see — a dynamic $method(), a call_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:

.join('
');

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.

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 static inside 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:serve is 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 a use at 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 a class inside 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 --coverage holds a lock, and could not run from inside the container it would have to start. It reads coverage/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 next composer 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.

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_tags keeps the anchor text and throws the href away, 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_tags removes 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-ID scheme distinguishing campaigns — the whole point of that header — must not have it overwritten by a generic one.
  • Precedence: bulk never 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-ID and Feedback-ID are 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

  1. 'email' => ['tracking' => true] in app.php. Absent means off.
  2. The message belongs to a listofferUnsubscribe() was called on it.
  3. 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.