19 October 2026¶
Seventy entries today:
db:migrate-from-mysql: the dry run names a backfill time column without an indexdb:migrate-from-mysql --defer-indexesdb:migrate-from-mysql --zero-date, NUL bytes counted, one conversion for move and verifydb:migrate-from-mysql: the dry run names triggers, views, routines and eventsdb:migrate-from-mysql: an enum keeps its list- The MCP endpoint lists only the scopes it takes (FW-157)
- MCP
initializecarries the analyst's instructions (FW-161) - A loopback redirect matches on any port (FW-159)
- Public MCP tools: roles and gates, hooks around every call, a missing scope named (FW-158)
- MCP scopes declared in app.php, for consent served by another application (FW-158 §3)
oauth.scope_refusal => 'drop': scopes a role may not grant left out, not refusing (FW-158 §4)- A box per scope on the consent page (FW-158 §5)
audited()names the account and the outcome;redacting()sparesuser(FW-162)database.translate_mysql: MySQL-written SQL run on PostgreSQL (FW-160, phase 1)PRAMNOS_TEST_SETTINGS: an application's suite against PostgreSQL (FW-160, phase 3)db:dialect-lint: SQL that would mean something else on PostgreSQL (FW-160, phase 2)AuthorizedApplications: one place to list and revoke what a person authorized (FW-163)- A test that left organisations in the shared test database
- A refused query says why, to a developer (FW-165 §4)
- A statement that failed inside a transaction no longer breaks the next one (FW-165 §1)
- Wrong second-factor codes are limited (FW-164 §1)
second_factor_everywhere: the second factor owed by the whole session (FW-164)- The lockout fails open, the two-factor reset forgets trusted browsers, sign-in devices translate (FW-164 §2–§4)
- Bytes on PostgreSQL:
Binary,bytearead as bytes, text kept in a blob named (FW-165 §5) db:migrate-from-mysql --implicit-defaults(FW-165 §6)- The crop dialog's tools are icons
- The second-factor gate runs the application's login flow, counts against the lockout, returns to the page (FW-166)
db:migrate-from-mysql:bigint unsignedbecomesbigint(FW-165 §2)db:migrate-from-mysql: long index and key names stay distinct (FW-165 §3)translate_mysql: an upsert added to after it was translated (FW-165 §7)translate_mysql: SHA2, CAST AS SIGNED, DAY/MONTH/YEAR, named locks, JSON_EXTRACT, DELETE through a join, an alias in HAVING, FOREIGN_KEY_CHECKS (FW-165 §8)db:dialect-lint: SQL built over several lines, prose left alone, ORDER BY,--schema(FW-165 §9)BinaryinprepareQuery()- Slow dashboard cards: shared figures and
dashboard:precompute(FW-167) - What's new: help pages announced to the administrators (FW-168)
- The second-factor gate: trusted browsers, a stopped session that stays stopped, a lockout said (FW-169)
dashboard:precompute: the application's cards, a controller, variants nobody saved (FW-170)db:migrate-from-mysql: binary and bit defaults carry over (FW-172)- The page cache: a flush that clears only the pages, the shared Redis, pages kept gzipped (FW-171)
- A second step owed before the application is up; the precomputed cards' controller (FW-173)
- A message's body read from the file an application keeps it in
- Click links issued from the message's send date, the same each time
- The debug toolbar's timers measured with the monotonic clock
- A sent message's body in the body store, read back as it was sent
mail:bodies-to-database: bodies from files into the compressed table- A templated message: kept once, filled for each recipient
- Bounces and blocked addresses: no mail goes to them, a password reset included
Helpers::getBrowser()parses an agent once a day, not once a call- device-detector's rules kept in the application's cache
- Erasure and retention reach the body store
WebhookHandler::onBranchStaged(): composer runs beside the live tree (FW-174)mail:hypertable: the mail log as a TimescaleDB hypertable, with the site upmail:bounces --imap: the bounce mailbox over IMAP, with a password or XOAUTH2- The Roadmap's mail-body entry removed: the body store shipped
- The change log: events without a model, on behalf of somebody, and a page at a time
Helpers::objectDiff(): a null property is not a removed one- Writing history into compressed hypertables: the policy paused for a move, the queue for spooled late rows
- The notification bell in every header and in the SPA, settling what is dealt with, and keeping the feed tidy
- A record's history, in the administration, on the site and in the SPA
- The Roadmap's remember-me entry brought up to date
- The auth screens in Greek, and whose server it is on every card
/Status: a public status page- New scaffolds register no auth addon: remember-me works without one
- Setting up an authorization server, end to end
- A recreated test database starts with nothing remembered about the old one
mail:bounces --imap --limitreads the newest unseen messages- An organisation's roles, written and checked for a fraction of the queries
- A controller segment reaches its class in any case
- The auth screens, finished: the theme's colour, closed registration, the account overview and organisation screens in Greek
- The page cache stores no page that prints who its visitor is
db:migrate-from-mysql: the dry run names a backfill time column without an index¶
Added¶
--dry-run --time-column=…names every table whose time column no index in MySQL starts with, and gives theCREATE INDEXto run before the backfill. A backfill window is a range on that column. Without such an index, every window reads the whole table, which for years of history means days. A column that comes second in a composite index is named too, since that index does not serve a range on it.
db:migrate-from-mysql --defer-indexes¶
Added¶
--defer-indexes, forfullandoperational, drops a migration-made table's plain indexes for the load and makes them again once the rows are in.- Each definition is recorded in
pramnos_migration_progressbefore the index is dropped. A load that dies leaves what is needed, and the next run of any phase makes the indexes again, with or without the option. - Unique indexes and those behind a constraint stay, because
ON CONFLICTand the keys need them.
- Each definition is recorded in
db:migrate-from-mysql --zero-date, NUL bytes counted, one conversion for move and verify¶
Added¶
--zero-date='1970-01-01 00:00:00'names what MySQL's zero date becomes in a column that takes no NULL. Such a row used to be refused, with no way around it but editing the source. The result counts the replacements.- Values that lose a NUL byte on the way are counted. PostgreSQL text cannot hold one, so it is dropped, and that used to happen without a word.
Changed¶
- One conversion,
Pramnos\Database\FromMysql\RowConverter, serves the move, the key comparison and the window digest. Each had its own copy of the encoding repair, so a fourth rule would have had to be added three times. What is verified is now what was written, by construction.
db:migrate-from-mysql: the dry run names triggers, views, routines and events¶
Added¶
- The dry run names MySQL's triggers, views, procedures, functions and events. None of them moves, because their logic is in MySQL's dialect and belongs to the migrations. The dry run says, for each, whether the target already has one of that name, so the one the migrations forgot stands out.
db:migrate-from-mysql: an enum keeps its list¶
Changed¶
- An
enumcolumn in a table the command makes istextwith aCHECKon its list. It used to be plaintext, so any word would do.- The list takes
''too. Outside strict mode, MySQL stores''for a value not on the list, and an installation's old rows hold it.
- The list takes
The MCP endpoint lists only the scopes it takes (FW-157)¶
Changed¶
- A protected resource's
scopes_supportedlists what that resource takes. An MCP connector asks for everything listed there, and the user's role allowed all of it. A Root user connecting to/api/1.0/mcpgot a token carryingsystem:admin,system:audit_readand the rest, to call tools that neededmcp. Now:- the MCP endpoint lists
mcp, the scopes its offered tools require, andoffline_access; - a path named in
'oauth' => ['resource_scopes' => [...]]lists those; - the site itself, and any other path, list every scope, as before.
- the MCP endpoint lists
- The MCP endpoint's
401names the same scopes inscope="…", which the MCP spec has a client prefer.
What breaks¶
A client that connected through the MCP endpoint and relied on getting other scopes as well. It asks for them, or the path is given its list. The Upgrade Guide has the row.
MCP initialize carries the analyst's instructions (FW-161)¶
Added¶
McpServer::setInstructions(). The text is sent asinstructionswithinitialize(MCP 2025-03-26), and a client passes it to its model.- The HTTP endpoint sets it from the application's analyst, through
AnalystAssistant::mcpInstructions(). It carriesabout(), the analyst's clock, its rule about people, that masked values stay masked, and which tool to reach for. That istoolGuidance(), new, or by default "schema and sql are the last resort".
Over MCP, everything the analyst knew was lost. Asked about an application's visits, a model wrote SQL against the schema instead of calling the tool made for visits.
A loopback redirect matches on any port (FW-159)¶
Changed¶
/oauth/authorizematches a loopback callback on any port (RFC 8252 §7.3).- A native client (Claude Code, an MCP inspector, a CLI) listens on a port it is given each run, so none could be registered for it. An installation had to pin one.
- Where
http://127.0.0.1/…,http://[::1]/…orhttp://localhost/…is registered, a request with the same scheme, host, path and query matches whatever its port. - Only the port is free, and only on loopback. Every other near miss is still refused, as the tests list them.
Public MCP tools: roles and gates, hooks around every call, a missing scope named (FW-158)¶
Points 1 and 2 of FW-158. The others follow in their own entries.
Added¶
- Who sees a public tool, beside its scope. An application where the role decides had to
rebuild the endpoint's server. Now there are two ways, and every one of them has to allow a tool:
ScopedTool::wrap($tool, $scope, minRole: Roles::ROOT);PublicRegistry::gate(callable(?object $user, ScopedMcpTool $tool): bool).
PublicRegistry::around()runs hooks around every public tool call.PublicRegistry::audited()writes anmcp.tool_callaudit row: user, tool, client, and parameter names, never values.PublicRegistry::redacting()passes every string of a result through the analyst'sRedactor.- Applications no longer wrap each tool themselves.
Changed¶
- A call to a tool whose scope the token lacks names the scope. The answer is
403, withWWW-Authenticate: Bearer error="insufficient_scope", scope="…", and the JSON-RPC error carries the scope indata.- It used to be "Unknown tool", which told an assistant nothing it could act on.
- A tool the user may not reach at all still answers as no tool, so its existence is not disclosed.
What breaks¶
A client or test that read "Unknown tool" for a missing scope reads the scope from the error instead. The Upgrade Guide has the row.
MCP scopes declared in app.php, for consent served by another application (FW-158 §3)¶
Added¶
'mcp' => ['scopes' => [...]]declares the MCP scopes inapp.php, so an application that holds none of the tools knows them too.- The fix is for authorization and MCP endpoint running as two applications. Consent there
refused
mcp:diagnosticsas unknown, and the endpoint's metadata listed onlymcpwhile its401asked for five. - Name the framework's scopes, or give your own with a description, such as
'mcp:people' => 'Find…'. NoScopesprovider is needed. - Each declared scope inherits
mcp.
- The fix is for authorization and MCP endpoint running as two applications. Consent there
refused
oauth.scope_refusal => 'drop': scopes a role may not grant left out, not refusing (FW-158 §4)¶
Added¶
'oauth' => ['scope_refusal' => 'drop']leaves out the scopes an account may not grant, and the request goes on with the rest (RFC 6749 §3.3).- The consent page asks for the rest, and the code carries those.
- A refresh after a role was taken away narrows the token instead of ending the connection.
- A request of nothing but such scopes is still refused.
- The default stays
refuse. Before this, an assistant asking for every listed scope could not connect at all for an administrator below the role of any one of them.
A box per scope on the consent page (FW-158 §5)¶
Changed¶
- The bundled consent views have a ticked box per requested scope. What stays ticked is what
the code, the token and the stored consent carry. Consent had been all-or-nothing.
openidandmcp, or whatoauth.consent_requirednames, have no box and are always granted.- Granting nothing, with nothing required, is a refusal.
- A consent view an application published earlier sends no
scope_choice, and grants everything asked for, as before. - The scope list on the page is open by default, so the boxes are seen without a click.
audited() names the account and the outcome; redacting() spares user (FW-162)¶
Added¶
PublicRegistry::audited()records the account a call was about, and whether it succeeded. It writes the row once the call is over, whichever way it ended.- The account defaults to the input's
userid, or itsid, so an audit screen's "who looked at this person" finds MCP calls. audited($event, $target)names another event or decides the account.
- The account defaults to the input's
Redactor::SELF_REDACTINGlists the tools that redact themselves (user). The analyst andredacting()both read it.
Fixed¶
- Over MCP,
redacting()masked the one emailuserhad been asked for. The analyst had spared that tool all along, so Ask and MCP gave different answers to the same question.
database.translate_mysql: MySQL-written SQL run on PostgreSQL (FW-160, phase 1)¶
Added¶
'database' => ['translate_mysql' => true]translates SQL written for MySQL on its way to PostgreSQL, throughPramnos\Database\Dialect\MysqlToPostgres.- It applies in
query(),execute(),prepare()andprepareQuery(), and to the query builder's raw fragments, so the admin datatables' hand-written conditions are covered. - Translated: backticks,
IFNULL,IF,ISNULL,LCASE/UCASE,RAND(),LIMIT a, b,UNIX_TIMESTAMP,FROM_UNIXTIME,CURDATE/CURTIME,DATE_FORMAT,DATE_ADD/DATE_SUB,GROUP_CONCAT,INSERT IGNORE,ON DUPLICATE KEY UPDATEandREPLACE INTO. - An upsert's conflict target is the table's one unique key among the inserted columns.
With two, or none, it is refused with
DialectExceptionrather than guessed. - Only constructs invalid in PostgreSQL are touched, string literals and comments never, so a statement translates the same twice and PostgreSQL-written SQL passes untouched.
- What would change meaning silently (a case-blind
LIKE, a looseGROUP BY) is left for the dialect linter, phase 2.
- It applies in
Off by default. It is a bridge for an application moving from MySQL.
PRAMNOS_TEST_SETTINGS: an application's suite against PostgreSQL (FW-160, phase 3)¶
Added¶
PRAMNOS_TEST_SETTINGSnames the settings file a test run uses in place of the bootstrap's, throughTestEnvironment::settingsPath().- A relative path is read from the project's root.
- A name that is not a file stops the run, rather than letting it fall back to the database it was meant to avoid.
Documentation¶
- The Testing Guide shows how to run an application's MySQL suite against PostgreSQL before
the move: a profile-gated PostgreSQL service, a settings file with
translate_mysql, the run, and how to read what fails only there.
db:dialect-lint: SQL that would mean something else on PostgreSQL (FW-160, phase 2)¶
Added¶
db:dialect-lint [paths] [--json]names, by file and line, the SQL the translator leaves alone because it would run on PostgreSQL and mean something else.like: case ignored on MySQL, not on PostgreSQL.group-by: a column neither grouped nor aggregated.double-quoted-string:"…"used as a value.
- It reads the code's string literals, statements and datatable fragments alike, through
Pramnos\Database\Dialect\DialectLinter.- Markup and an operator's name alone are not taken for SQL.
// dialect-okmarks a line that has been looked at.- It exits
1while anything is found.
AuthorizedApplications: one place to list and revoke what a person authorized (FW-163)¶
Added¶
Pramnos\Auth\AuthorizedApplications, withforUser(),byClientId()andrevoke($userId, $appId, ?$byUserId). The account screen and an administrator acting for a person now do the same thing.byUserIdlands asrevoked_byin the activity record.Account::applicationsUrl(): where revoking returns to, for an area that serves the screen elsewhere.
Fixed¶
- A consent with no live token was invisible, and could not be withdrawn. It still approved
the next authorization without asking. The list now includes it, with
consented_atandscope.last_usedis the latest of last use and creation. - A deactivated application could not be revoked ("Application not found"), while it stayed listed and its refresh tokens stayed live. It is now found and revoked.
EndSessionrevoked with status 0, the account screen with 3. Both write 3 withremovedate.- The revocation messages were not translatable. They go through
t().
A test that left organisations in the shared test database¶
Fixed¶
TaskHandoverTestfailed now and then in a full run.RoleAssignmentTestandPermissionResolverOrganizationTestwrote organisations 3, 5 and 99 into the shared test database and never removed them. A later test relying on an installation that keeps no organisations then found organisation 3, depending on the order the suite ran in. Both now remove what they wrote. This was the intermittent failure of a full run seen two days running.
A refused query says why, to a developer (FW-165 §4)¶
Changed¶
QueryException::getMessage()carries the driver's text on a developer's machine and in an application's test run. A failing test used to read «The database refused the query» and nothing more.- "A developer's machine" means
APP_DEBUGorDEVELOPMENT.TestEnvironmentturns it on for an application's run. QueryException::$detailedoverrides either way.- Production names nothing, as before.
getDetail()says everything once, either way.
- "A developer's machine" means
Fixed¶
- A datatable whose query PostgreSQL refused said only «query returned no result». Its message now carries the driver's reason beside the SQL it already had.
A statement that failed inside a transaction no longer breaks the next one (FW-165 §1)¶
Fixed¶
- On PostgreSQL, one failed statement inside a transaction broke that statement for good.
The sequence:
- The failure aborted the transaction.
- The aborted transaction refused the
DEALLOCATEof the statement's plan. - The connection forgot the plan while the server kept it.
- In a later transaction, preparing the same SQL collided with it: «prepared statement already exists». That error aborted the later transaction too, so the recovery that follows could not run.
One application counted 1,034 such errors, from a user save after a duplicate e-mail.
- Now:
- A plan whose DEALLOCATE the transaction refuses stays known, and is reused.
- Inside a transaction, a plan is looked for on the server before it is prepared, so no
collision is raised.
- A plan found there under the SQL's hash is used.
Wrong second-factor codes are limited (FW-164 §1)¶
Fixed¶
- A pending login took any number of wrong second-factor codes. A password was enough to
guess a six-digit code for as long as the challenge lived.
- Each wrong code now counts against the pending login, and against the account's lockout like a wrong password.
- After
auth.second_factor_attemptscodes (default 5) the pending login is ended, and the next try starts from the password. - A lockout the failures set off is answered as
LOCKED. - This covers TOTP, backup codes, mailed codes and every registered factor. A factor whose gateway is down refuses without counting.
second_factor_everywhere: the second factor owed by the whole session (FW-164)¶
Added¶
'auth' => ['security' => ['second_factor_everywhere' => true]], off by default.- A session the login flow did not finish, for an account with a second factor, is not counted as signed in. It becomes a pending login, and the login page shows the second step.
- The check is in
Session::staticIsLogged(), so it holds for pages, controllers,/oauth/authorizeand the API's session path alike.
Pramnos\Auth\SecondFactorGateandLoginFlow::demandSecondFactor()implement it.- A finished login settles the session.
- An account with no factor, or a trusted browser, is settled.
- Impersonation settles the account it becomes.
- A login already waiting keeps its wrong-code count.
Before this, where one session served a site, an administration and an API, a password alone signed in through a door that did not ask for the factor.
The lockout fails open, the two-factor reset forgets trusted browsers, sign-in devices translate (FW-164 §2–§4)¶
Fixed¶
- A login lockout whose table could not be reached failed every sign-in. It now goes on
without the lockout, logging the problem once per request.
auth.security.lockout_fails_closedrefuses instead, for an application that would rather close. auth:twofactor-resetleft the account's trusted browsers trusted. One would go on skipping the factor enrolled next. The command also:- writes
auth.twofactor_resetto the audit log, with the administrator given by--by; - promised a mailed code even where the application has no email factor. It now says what the next sign-in asks for.
- writes
SignInFingerprint::describe()was English in every language. It goes throught(), so «Chrome on Windows» in a security mail can read as the language reads.
Bytes on PostgreSQL: Binary, bytea read as bytes, text kept in a blob named (FW-165 §5)¶
Added¶
Pramnos\Database\Binarybinds bytes as bytes. On PostgreSQL that goes throughpg_escape_bytea(), which a bound string could not do: bytes that are not UTF-8 were refused, and a backslash was read as an escape. One application's single binary INSERT failed 1,343 tests.- The dry run of
db:migrate-from-mysqlnames a blob that holds text, and says whether it is JSON too, so the migration can make ittextorjsonb. --blob-as-text=table.columnmakes such a columntextin a table the command creates.
Changed¶
- A
byteais read back as the bytes, throughquery(),execute()and the builder alike. It used to be PostgreSQL's hex text, where MySQL gave the bytes. The PostgreSQL value conversion, which had four copies, is now one (Result::fromPostgres()).
What breaks¶
Code that decoded the hex of a bytea itself drops that decoding. The Upgrade Guide has the row.
db:migrate-from-mysql --implicit-defaults (FW-165 §6)¶
Added¶
- The dry run reads the MySQL server's
sql_mode. Out of strict mode, it counts the target'sNOT NULLcolumns with no default on either side. MySQL fills an insert that leaves one of them out, and PostgreSQL refuses it. One application had 1,809 of them. Keys are not counted. --implicit-defaultsgives those columns MySQL's default:'',0,false, an emptybytea. Dates are named rather than defaulted, because MySQL's zero date has no PostgreSQL value.
The crop dialog's tools are icons¶
Changed¶
- Rotate left, rotate right, flip and reset in
pf-image.js's crop dialog are icons. Each button's words are itstitle, shown on hover, and itsaria-label, so a screen reader still names it. The inline SVG is drawn incurrentColorand follows the theme. Cancel and "Use this picture" stay as words. Each theme'scomponents.cssstyles.pf-image-icon.
Added¶
document.createElementNS()inpramnos-testing's DOM, so a page script that draws SVG can be tested. The element keeps its name's case and itsnamespaceURI.
The second-factor gate runs the application's login flow, counts against the lockout, returns to the page (FW-166)¶
Fixed¶
- The gate ran the framework's
LoginFlow, not the application's. A subclass with its own trusted devices decided what the login form asked, but not what a session owed. Both now make the flow withLoginFlow::make(), which reads'auth' => ['login_flow' => …]. - A session the gate stopped was not counted against the lockout. Its pending login carried no identifier, so its wrong codes locked nothing, and an account already locked was handed a code form. The pending login now carries the account's username as the form would, and a locked account gets none until the lockout lifts.
Added¶
auth.security.second_factor_url: a page opened in a browser by a stopped session is sent to the second step, and the account screens return to that page once it is done (SecondFactorGate::RETURN). A form post, an XHR or an API call is refused, not redirected.
db:migrate-from-mysql: bigint unsigned becomes bigint (FW-165 §2)¶
Fixed¶
- A table made by the migration gets
bigintfor abigint unsigned, notnumeric(20,0). Anumericcan be neither an identity column nor the target of a foreign key, so the conversion stopped. One application had 15 such tables.
Added¶
- The dry run counts the values a
bigintcannot hold, per column, over the whole table: rows above9223372036854775807, each named with what to do.
What breaks¶
A column that holds such values must be made numeric(20,0) in a migration. The Upgrade Guide
has the row.
db:migrate-from-mysql: long index and key names stay distinct (FW-165 §3)¶
Fixed¶
- A foreign key whose name was cut to PostgreSQL's 63 bytes could be lost. Two names that
shared their first 63 bytes became one. The second key read as "already exists" and was never
made, and the run said nothing. One application lost 6 keys this way. A name longer than 63
bytes is now cut to 54 and ends in a hash of the whole (
Migrator::identifier()), and the cut never splits a character.
translate_mysql: an upsert added to after it was translated (FW-165 §7)¶
Fixed¶
, n = n + 1appended to an upsert thatprepareQuery()had already translated failed with "column reference is ambiguous". The statement was by thenON CONFLICT … DO UPDATE SET, and nothing in it looked like MySQL any more, so the second pass left it. In one application this broke every request with a signed-in user. A statement already in that form now has its bare self-references qualified with the table's name, quoted ones ("n" = "n" + 1) included, as anON DUPLICATE KEY UPDATEalways had.
translate_mysql: SHA2, CAST AS SIGNED, DAY/MONTH/YEAR, named locks, JSON_EXTRACT, DELETE through a join, an alias in HAVING, FOREIGN_KEY_CHECKS (FW-165 §8)¶
Added¶
These are the constructs one application's suite measured as missing. Each is now translated:
SHA2(x, n)→encode(shaN(…), 'hex');CAST(x AS SIGNED|UNSIGNED)→CAST(x AS bigint);DAY(),MONTH(),YEAR()→EXTRACT(…)::int;GET_LOCK/RELEASE_LOCK→ advisory locks onhashtext(name), without waiting;JSON_EXTRACTwith one literal path →#>, andJSON_UNQUOTE→#>> '{}';DELETE a FROM t a JOIN …→DELETE … USING … WHERE;- a select-list alias named in
HAVING→ its expression, in subqueries too; SET FOREIGN_KEY_CHECKS→session_replication_role.
An integration test runs each of them on PostgreSQL and checks MySQL's answer. The Database API Guide lists each one's limits.
db:dialect-lint: SQL built over several lines, prose left alone, ORDER BY, --schema (FW-165 §9)¶
Fixed¶
- SQL built over several lines is read as one statement. That covers a string with variables
in it, strings joined with
., and a variable built up with.=. The linter read each piece alone, so aGROUP BYwas never in the same piece as itsSELECT. On one application it found 1 of the 13 statements its PostgreSQL suite failed on, and it now names 24 columns across them. - Prose is not SQL. A prompt that says like "daily" was read as a
LIKEand a double-quoted string. Text made of sentences is no longer judged.
Added¶
group-bycoversORDER BYtoo: a groupedSELECTordered by a column it does not group, and aSELECT DISTINCTordered by a column it does not select. A grouped key lets its table's other columns through, as in PostgreSQL.--schemareads column types and primary keys from the application's database (DialectLinter::schema()). With it, a newimplicit-castrule names a text column compared with a number, and keys are judged exactly.
Binary in prepareQuery()¶
Added¶
prepareQuery()takesnew Binary($bytes)for a%s. Until now onlyexecute()and the query builder did. On PostgreSQL the bytes are written as abytea's hex form, on MySQL with its escape. On one application's PostgreSQL suite, 2,502 of 2,670 errors were oneprepare()ofhex2bin()andinet_pton()values that PostgreSQL refused as text. Wrapping the values inBinaryis now the whole fix.
Slow dashboard cards: shared figures and dashboard:precompute (FW-167)¶
Added¶
- A card can be shared (
Widget::shared()). Itsrender()is split intodata(), the figures, andpresent(array), which draws them for the person looking. The dashboard keeps the data in the cache'sdashboardcategory underdataKey()(Dashboard\WidgetData), and people whose cards differ only in title or chart share an entry. Each card handed to a view carriesat, when its figures were worked out. dashboard:precompute [--area=admin]works out every shared card with its default settings and every setting saved in the area's layouts. One cron line, and nobody waits for a card. Entries are kept for'dashboard' => ['data_ttl' => …], 300 s by default.
Changed¶
- The framework's slow cards are shared:
logs.errors,ai.spend,messages,users.active,database,queueandapi.performance. On one applicationlogs.errorstook 1.1–1.4 s on every dashboard load, reading every log. All the other cards together took about 60 ms. Its figures, like the others', can now be up todata_ttlold. The Upgrade Guide has the row. Widget::render()is no longer abstract. A card that defines it is unaffected.
What's new: help pages announced to the administrators (FW-168)¶
Added¶
whatsnew:in a help page's front matter announces it (Pramnos\Assist\WhatsNew). A window over the administration shows its lines until each person closes it. It opens without a script: the newest three in full, each with More, and the rest counted.- Close and More are a POST with the session's token to
/admin/Help/whatsnew(HelpController::whatsnew()). It marks everything announced as seen in the person'swhatsnew.seensetting, and goes back to the page, a path of this site only, or to the post. - Somebody who has never had it starts with everything dated before today seen, so the day's announcement still reaches them but not the history.
- A header button shows while something was announced in the last fourteen days, and opens the window again.
- Wiring: themes written by
initcarry both hooks, and each theme'scomponents.cssstylespf-whatsnew. Settings:'help' => ['whatsnew' => ['shown', 'recent_days', 'min_usertype']]. - Help pages read
date:where they have noupdated:, as a changelog post does, so the dashboard'shelp.newscard orders them by it too.
The second-factor gate: trusted browsers, a stopped session that stays stopped, a lockout said (FW-169)¶
Fixed¶
- A browser the account trusts owes no second factor at the gate either.
LoginFlow::demandSecondFactor()asked only whether the account had a factor. A remember-me login in a browser trusted with "Don't ask again on this device" was therefore asked again, where the form would have let it through. - A stopped session was a guest from its second request on. The gate signed it out and
redirected the first page only. The next page saw nobody signed in and was drawn for a guest.
The session now keeps what it owes (
SecondFactorGate::OWED).Session::staticIsLogged()sends every page to the step again while the login waits or the account is locked out, at most fifteen minutes, and finishing the login clears it. - The step could not say that the account was locked. When the gate meets a lockout,
SecondFactorGate::lockedFor()gives the seconds left, and the login page shows it as locked rather than as expired.
dashboard:precompute: the application's cards, a controller, variants nobody saved (FW-170)¶
Fixed¶
- The command worked out the console's application's cards. It ran as the console's
application, so on a console that boots the site it found only the framework's 7 cards, not
the administration's. It now uses
--app=<name>, or the area's own application whereapp/<area>.phpexists, and puts the console's back afterwards. - A card's
data()had no controller while precomputing. Each card is now handed one of the application's, as the dashboard hands its own.
Added¶
Widget::precomputeSettings(array $settings): array. A card spreads one setting into the variants its data has but nobody saves, such as one per market looked at lately. By default it returns the setting alone.savedLayouts($area)is documented as the application's to extend.
db:migrate-from-mysql: binary and bit defaults carry over (FW-172)¶
Fixed¶
- A binary column's default was dropped, and so was a
bitone. Abinary(8) NOT NULL DEFAULT ''becamebytea NOT NULLwith no default, and an insert that left the column out was refused where MySQL had filled it with eight zero bytes. On one application that was the INSERT every request makes, about 2,500 tests. The default is now the bytes MySQL stores: MySQL 8 reports them as0x…, MariaDB as an escaped literal, and abinary(n)is padded to its length as MySQL pads on insert. They become'\x…'::bytea. Abit(1)default becomestrueorfalse, including MariaDB's bareb'1', which was read as an expression.
The page cache: a flush that clears only the pages, the shared Redis, pages kept gzipped (FW-171)¶
Fixed¶
- Flushing the page cache emptied the whole Redis database. The adapter was built without
the page cache's prefix, so
flush()andpagecache:purge --allreachedRedisAdapter::clear(''), which isFLUSHDB. That took the application's cache, its rate limiters and everything else with it. A file store emptied the whole cache folder. The adapter now carries the prefix, and a test on a real Redis keeps a key beside the pages through a flush. - Redis was localhost unless the
cachesettings said otherwise. On a Docker network every request missed and logged an error. The server is now the page cache's ownredis, then thecachehostname, then the shared connection, asCachefinds it.
Added¶
'compressAtRest' => truekeeps only the gzipped body and unzips it for a client without gzip. On one application's production that is 11,124 pages in about 5 GB, at about a sixth of the size.pagecache:purge --config=<file>reads the configuration from a file of the application's own.
What breaks¶
A file store's pages move into a subfolder named after the prefix, so those stored before are not read again or purged. The Upgrade Guide has the row.
A second step owed before the application is up; the precomputed cards' controller (FW-173)¶
Fixed¶
- A pending second step was forgotten when asked about too early. A front controller asks
Session::isLogged()before it creates the application, to decide on its page cache. Without an applicationsecond_factor_everywherereads as off, soSecondFactorGate::stillOwed()cleared what the session owed. The page was then served as to a guest, or from the page cache. Without an application the session now counts as owed and nothing is cleared. dashboard:precomputegave the cards a plain controller, whosegetModel()did not find an administration area's models. It now uses the application's dashboard controller:'dashboard' => ['controller' => …], orControllers\Dashboardin its namespace.
A message's body read from the file an application keeps it in¶
Added¶
Pramnos\Email\Body::of($row)reads amailsrow's body fromcontent, or from the gzipped file itspathnames relative to the application's root, which is how applications have kept them for years. The history screen showed those messages as "No body content."; it, its report, the outbox worker and the debug panel's sign-in code now read the body.Messaging\Mail::body()does the same. A path is read only from inside the root and only as a.gzor.htmlfile.
Documentation¶
- The mail body storage design records what was measured on production, what was decided and the phases. This is the first of them.
Click links issued from the message's send date, the same each time¶
Added¶
MailAction::tokenUntil($action, $claim, $expires), andTracking::linkSentAt()andwrapLinksSentAt(). A click token's expiry is counted from the moment the message was sent as, so the same message, link and moment give the same link. A stored body can then be given its links back exactly as they were sent (mail body storage, phase 2). These are new methods becauseMailActionandTrackingare not final: the existing ones keep their signatures and count from now.Emailsends as one moment.send()takes it before composing and records it as the row'sdate. The outbox worker hands a queued message its row'sdate, so the links of a queued message count from when it was queued.
The debug toolbar's timers measured with the monotonic clock¶
Fixed¶
- A named timer could measure a negative length.
TimeCollectortook a timer's length from the wall clock, which a virtual machine corrects in steps. A step back during a 5 ms timer measured −553 ms, and a test failed on it. A timer's length is now the monotonic clock's (hrtime), and its place on the timeline is still the wall clock's.
A sent message's body in the body store, read back as it was sent¶
Added¶
Pramnos\Email\BodyStorekeeps a sent message's body where'email' => ['bodies' => ['store' => …]]says. Withauto, the default, that is themail_bodieshypertable on TimescaleDB (a new migration, compressed after 7 days), and files undervar/emailselsewhere, or on a storage disk.contentkeeps it in the row as before.- The body is kept before what differs per recipient: no tracking, and the framework's
unsubscribe link as a placeholder. A newsletter is one body. The Gmail actions' structured
data is kept with it, and
hashis its md5. Body::of()gives it back as it was sent, to the byte. The test compares it with the HTML that reached the transport, through a file, a disk, the database, and a compressed chunk.- A store that cannot be written keeps the body in the row.
Fixed¶
- The kept body is now the one that was sent. The framework kept a body without its tracking or its structured data, which are added as the message is sent. A message's report therefore never found the pixel or the Gmail actions of a real send. It now reads them back.
What breaks¶
mails.content is empty for a message sent from now on. The Upgrade Guide has the row.
mail:bodies-to-database: bodies from files into the compressed table¶
Added¶
mail:bodies-to-databasemoves the bodies of sent messages kept as files intomail_bodies, for an installation that has TimescaleDB now: an application's.html.gz, the store's.body.gz, files on a storage disk, and with--include-contentthose in the row.- Each row reads exactly as before: it points at
mail_bodies(path=db:). - It runs in batches and survives being stopped: a transaction per batch (
--batch,--limit), and a run cut short is finished by the next. --dry-runcounts what there is to move, and a file that is gone is counted and its row left. The files are not deleted, because many rows share one.- Measured on one application's real bodies, the table holds them in about a seventeenth of their size, against roughly 1.2× for one gzipped file each once the 4 KB blocks are counted.
A templated message: kept once, filled for each recipient¶
Added¶
Email::setBodyTemplate($body, $values, $user)hands over the template and the values rather than the filled body. The message is wrapped as the template and filled afterwards, so the body kept is the wrapped template, once for every recipient of it. The values are kept in the newmails.bodyvarscolumn, and reading fills it again, to the byte.Pramnos\Email\TemplateValuessupplies the account's values: names, username, email, id, the site and its sign-in page, escaped because they are the account's text. An application adds its own withTemplateValueProvider('email' => ['template_values' => …]), and those win.- A migration adds
bodyvars, nullable and at the end, which MySQL 8 and MariaDB add instantly. Without it, a templated message is kept filled.
Bounces and blocked addresses: no mail goes to them, a password reset included¶
Added¶
Pramnos\Email\Bouncesrecords bounces and blocks, and says why an address is refused: blocked, hard-bounced once, soft-bounced 15 times or within the last day. Each threshold is set under'email' => ['bounces' => …].send(),queue()andmail:flushrefuse such an address, writing themailsrow with the reason. A message to several addresses leaves out the refused ones.- A
5.1.xrefusal at send time is recorded as a hard bounce. mail:bouncesreads delivery reports (RFC 3464) from a folder of.emlfiles or from standard input, so it can be a mail server's delivery target. It also blocks, clears and looks up an address.- The
bouncesmigration uses the layout applications already keep. An existing table keeps its rows and gainsblockedandreason. - A message's page in Email History says whether mail goes to its recipient, with Stop all mail to it and Send to it again.
Fixed¶
- The bootstrap and plain-css email pages read an undefined
$sentAtfor the date. The plain-css page's container also had a brokenstyleattribute.
What changes for an application¶
- Mail is now refused to addresses already in
bounces. An application's own table with recorded bounces refuses mail sent through\Pramnos\Email\Emailonce the migration has run. See the Upgrade Guide row for the thresholds that keep the old behaviour.
Helpers::getBrowser() parses an agent once a day, not once a call¶
Fixed¶
- The page cost of
getBrowser()withmatomo/device-detector: it parsed the agent on every call. That meant reading the package's regex files each time: about 60 ms for a desktop Chrome and 320 ms for a phone or a Mac, on every page a signed-in visitor opened. - The answer is now kept by agent, in the process and for a day in the cache's
browserscategory. A repeated agent costs nothing, measured at 735 ms for the first call and 0 ms after, in a new process too. A cache that cannot be reached means parsing again.
device-detector's rules kept in the application's cache¶
Added¶
Pramnos\General\DeviceDetectorCacheimplements device-detector'sCacheInterfaceoverPramnos\Cache\Cache, in thedevice-detectorcategory, so Redis where it is configured.getBrowser()hands it to every parse.- What it saves: reading the rules from YAML was nearly all of a parse,
mobiles.ymlalone about 230 ms for every phone, Mac or unknown agent. A per-agent answer alone still left a phone's first page at that cost, because phone agents vary by model, OS version and browser version. - Measured: a phone agent never seen before, in a new process, takes 47 ms where it took 726 ms.
- Updates are safe: the package's version is in each key, so
composer updatereads the new rules. A store that is down means reading YAML again.
Erasure and retention reach the body store¶
Added¶
Pramnos\Email\MailPersonalData::erase()pseudonymises every message to an address: no address, no name, the subject[erased], no body, no outbox state.- It matches however the address was stored, PostgreSQL included.
- The row stays, so the log still says a message of this module went out then.
- Tracking rows keep their counts and lose the address.
AccountErasure::erase()calls it with the account's address.MailPersonalData::export()is the right-of-access half: every message to an address, with its body as it was sent. An account's data export carries it as itsmailsection, labelled Mail sent to you.BodyStore::discard()removes the kept bodies of a set ofmailsrows: theirmail_bodiesrows, their files, their files on a disk. A file another row still names is left.mail_bodiesandemailtrackingare now declared personal data, sodb-inspectwithholds their rows.
Fixed¶
mail:prunewith the body store left bodies behind. Stripping emptiedcontentand left the body in the store; pruning deleted rows and left their files andmail_bodiesrows behind. Both now remove the body from wherever it is kept.- PostgreSQL now sweeps in batches too. Both commands work in batches by id through the query builder, where PostgreSQL ran one statement over the whole range.
pruneRecipients()goes through the query builder too.
What changes for an application¶
- An erased account's mail rows are kept pseudonymised, and stripped rows lose their
path. See the Upgrade Guide row.
WebhookHandler::onBranchStaged(): composer runs beside the live tree (FW-174)¶
Added¶
onBranchStaged($branch, $after, $stage, $composer)maps a branch to a staged deploy:- export the branch to a stage directory beside the checkout;
- copy the current
vendor/into it; - run
composer installthere; - go live in one command,
git reset --hardandvendor/replaced by a rename; - clean up and run the application's commands.
- Why: a reset followed by
composer installrewritesvendor/under live requests. Every deploy that changedcomposer.lockserved seconds of fatals. - Measured by the application that asked: composer took 3.6 s outside the live tree, and the live change 0.73 s, almost all of it the reset.
- A composer that fails leaves the live tree untouched.
- A
vendor.olda failed deploy left behind is removed first, so the rename cannot movevendor/into it.
mail:hypertable: the mail log as a TimescaleDB hypertable, with the site up¶
Added¶
mail:hypertable, withPramnos\Email\MailHypertableunderneath, makesmailsa hypertable on TimescaleDB: monthly chunks, compressed after ninety days.- The conversion only renames and creates, in 23 ms:
mailsbecomesmails_legacy, and a newmailsis made like it. dateis widened tobigint.- The primary key is
(id, date). - The status index becomes
(status, date). - The id sequence passes to the new table.
- The old rows move newest first, each batch in one statement and resumable; 60,000 rows/s measured.
datestays a Unix timestamp, so no reader or writer changes.- Erasure,
mail:pruneand a person's copy read both tables while the rows move.
Fixed¶
Database::getInsertId()on PostgreSQL returned the wrong id after an insert that opened a chunk. It wasLASTVAL(), and TimescaleDB takes a sequence value of its own when it opens a chunk, so the insert got the chunk's number. It now reads the table's own sequence. The lookup cannot fail inside a transaction.
mail:bounces --imap: the bounce mailbox over IMAP, with a password or XOAUTH2¶
Added¶
Pramnos\Email\ImapClient: just enough IMAP to read a mailbox, without theimapextension PHP no longer ships.- It connects with TLS, STARTTLS or neither.
- It signs in with a password, or with an OAuth2 access token over XOAUTH2.
- It lists the unseen messages, or those read before a day, and reads one without marking it.
- It marks messages seen, deletes and expunges.
- It comes from an application that had written it for a press inbox. That application can now use the framework's copy, with the same methods.
Pramnos\Email\BounceMailboxandmail:bounces --imapread the bounce mailbox. Each delivery report is recorded and marked seen; anything else stays unread.- The mailbox is configured under
email.bounces.mailbox, or else in theimap_*settings an application already keeps. - With a refresh token, the access token is fetched and XOAUTH2 used, so Gmail needs no Gmail API.
The Roadmap's mail-body entry removed: the body store shipped¶
Documentation¶
- The "Where a sent message's body is stored" entry is gone from the Roadmap. All seven phases of the design shipped today. How the store works is in the Email guide, and the design page keeps the decisions.
The change log: events without a model, on behalf of somebody, and a page at a time¶
Added¶
ChangelogWriter::event($entity, $itemid, $event, …, by:, scope:, at:, source:)records an event on any record without a model: a user, an organisation, an imported row.Model::logEventAs($userid, …)records an event on behalf of somebody other than whoever is signed in. A console command or an import acting for a person can now say who.ChangelogReader::listing($filters, $limit, $offset, $order)gives a page and the total for a listing screen. It filters by record, event, logtype, user, source, time window and tenant, and refuses a filter it does not know.- An application's own log can be moved in.
at:,by:andsource:let a command of the application's own bring itsuserlogoritemlogover: each row at its own moment, by whoever acted. There is no framework import, because before this there was no standard log to import from. The Change Feed guide shows the shape of such a command. 'retention' => nullwritten in an app.phphypertablesoverride removes the retention policy an earliertimescale:ensureinstalled. This is a behaviour change, with an Upgrade Guide row.
Fixed¶
logEvent()wrotecreated_atwithdate('c'). MariaDB drops the offset, and in strict mode refuses the row. Events now go throughInstant::local().
Why now¶
- These are the three items one application's plan for moving its changelog onto the framework
filed against it: a listing reader, attribution, and the retention trap. They also let a
second application move its
userloganditemlogover with a command of its own.
Helpers::objectDiff(): a null property is not a removed one¶
Fixed¶
objectDiff()checked whether a property exists withisset().isset()is false for a property that is there and null, so a property null after the comparison was reported as removed, and one null before as added.- One application logs the
removedhalf on every user save, as a "data removal" entry. It had almost as many of those as of real changes: 78,861 against 79,691 in its development database. Each listed fields nobody touched, all null. - A field cleared to null was filed as removed, losing what it had been changed from.
- Now
array_key_exists()decides. A value that became null is a change, with its original. Null on both sides is nothing.removedandaddedhold only properties one object does not have. - This is a behaviour change, with an Upgrade Guide row.
Writing history into compressed hypertables: the policy paused for a move, the queue for spooled late rows¶
Measured¶
- TimescaleDB 2.26 takes writes into a compressed chunk, but slowly. A single insert took 3.18 ms against 0.52 ms into an uncompressed chunk. 50,000 rows in bulk took 129 s, against 1.3 s to decompress the chunk, insert them and compress once.
- Updating or deleting a few rows there is cheap: 5–7 ms. Erasure and retention are not affected.
Added¶
SchemaBuilder::withCompressionPaused($table, $callback)unschedules the table's compression policy for a bulk write. It reschedules it however the write ends, and then runs it once so that what aged past it is compressed straight away. Rows are readable as they are written.
Fixed¶
mail:bodies-to-databaseandmail:hypertableraced the compression policy. A long move could have a chunk compressed while it was still filling it, and pay the slow price for the rest of that chunk. Both now run insidewithCompressionPaused(). The Email guide says to move the bodies before convertingmails, because moving a body updates itsmailsrow.deferred_writeshad no effect on spooled rows. The change log tables declared it, but the spool inserted every row directly, so an event an import dates in the past went straight into its compressed chunk. The spool's drain now sends a row dated before the cutoff to the deferred-write queue, whichtimescale:drainwrites hourly, a chunk at a time. This is a behaviour change, with an Upgrade Guide row.
The notification bell in every header and in the SPA, settling what is dealt with, and keeping the feed tidy¶
Added¶
- The scaffolded header draws the bell.
NotificationBell::headerButton()is drawn for whoever is signed in, in all three UI systems. One header serves the site and the administration, so the bell is in both. It draws nothing where there is nonotificationstable. Notification\FeedApi::routes($r)serves the feed to a SPA in the API's envelope, with a bearer token or the page's session.initmounts it with theauthfeature.NotificationBell.sveltein every Svelte SPA, drawn by the shell for whoever is signed in.- It is built from the bell an application had written for itself, and uses the same
pf-notify-*classes as the server-rendered one. - It reloads when opened and when the tab is brought back, every
intervalseconds while visible, and onpf:notifications:refresh. - It has a Vitest file.
Feed::settleAbout($keys, $userId)marks read the notifications whoseaboutnames something now dealt with: everybody's, or one person's. It also comes from that application.notifications:prune, daily, removes notifications read more thankeep_read_daysago and never removes an unread one. Without a number it does nothing.Feed::prune()does the same from code.
A record's history, in the administration, on the site and in the SPA¶
Added¶
Changelog\History::page($filters, $page, $perPage)gives a record's history the way people read it. Each line has who did it (a name, or nobody for the system), what happened, as a rendered sentence, and when, plus a save's changed fields. It is built onChangelogReader::listing(), and generalises what one application had written for its team's activity.History::allows()decides who may read it.- By default: whoever may open the administration, and anybody reading their own account's history.
History::allowUsing()replaces the rule.Html\HistoryPanel::render($entity, $id, $options)draws it in a server-rendered page, in the administration or on the site. Older pages are links, and no script is needed.Changelog\HistoryApi::routes($r)serves it to a SPA.initmounts it with thechangelogfeature.HistoryPanel.sveltein every Svelte SPA draws it there, with Older appending, and comes with its Vitest file.- The
pf-history-*classes are in the three themes' stylesheets, shared by both panels.
The Roadmap's remember-me entry brought up to date¶
Documentation¶
- The entry said a built-in remember-me was still to come. It shipped with the token-per-sign-in
work:
Auth\RememberTokens, andAuth::rememberLogin()behindauthCheck(). - What is left: new scaffolds still register the
UserDatabaseaddon for it, and the addon-free path still has to be verified in a real application.
The auth screens in Greek, and whose server it is on every card¶
Added¶
- The framework translates its own screens.
resources/language/<code>.phpholds the phrases of the sign-in, second-factor, registration, consent, device-code, account and status screens, and the messages the auth controllers flash.Language::load()merges it under the application's catalogue, found byLangShortor by the language's name (greek→el), so a phrase the application words itself keeps its wording. Greek ships: 482 phrases. - Every visible phrase of those views goes through
t(), in all three themes, and theAccountandTwoFactorAuthflash messages with them. OAuth error responses stay English: programs read them. Auth\Brand::current()and theauth_brandpartial: the logo, or the name, at the top of every card screen — sign-in, second factor, approve, messages, the password screens, registration, the consent screen and the device screens — from theauth_brand_*settings, elsesitename. Somebody who arrives from a link sees whose server is asking before it asks.auth_home_link: the public screens before sign-in carry a link back to the site.FrameworkGreekCatalogueTestfails when a screen gains a phrase with no Greek line, or a line whose placeholders differ from its key's.
Fixed¶
- The plain-CSS privacy settings page left the marketing checkbox's
<label>and<div>open and drew the analytics block twice. - A registration message split over two literals could never be translated: the key looked up was its first half.
/Status: a public status page¶
Added¶
Application\Controllers\StatusextendsHealthwith a page anybody may open: the overall verdict in words and each check's verdict by name, run as the page loads. It never shows a check's message or details, which describe the host; administrators read those on/health.initscaffoldssrc/Controllers/Status.phpbeside the health wiring, and each theme shipsstatus/status.html.php.
New scaffolds register no auth addon: remember-me works without one¶
Changed¶
initno longer registers theUserDatabaseaddon. Its comment said remember-me needed it;Auth::authCheck()signs a remembered browser back in throughrememberLogin()whenever no auth addon is registered. Applications that register the addon keep working as before.- A characterization test signs in from the remember-me cookies with no addon registered, on MySQL, PostgreSQL and TimescaleDB.
Documentation¶
- The Authentication Guide says remember-me needs no addon; the Roadmap keeps only the check in a real application.
Setting up an authorization server, end to end¶
Documentation¶
- Setting up an authorization server: scaffold it, generate its keys and first administrator, brand it, its language, the first client, status and health, and what to change and what to leave to the framework.
A recreated test database starts with nothing remembered about the old one¶
Fixed¶
- A test database dropped and created again under its name kept two caches of the old one.
The
var/migrations/*.verifiedmarkers said its migrations were done, so none ran and every test failed on a missing table with nothing mentioning migrations. The connection's SQL cache answered the dropped database's settings, so a migration that writes a setting only when it is unset wrote nothing. Both are keyed on the connection's name.TestEnvironmentnow forgets both at the one moment anything knows the database is new: when it creates it — the first run, aftertest_database.rebuild_abovedropped it, or after somebody did by hand. A database it finds already there keeps its markers.
mail:bounces --imap --limit reads the newest unseen messages¶
Fixed¶
- Behind a backlog of unread replies, a new delivery report was never read.
mail:bouncesmarks only reports as seen; a reply stays unseen for a person, so in a mailbox that also receives replies the unseen messages only grow.BounceMailbox::unseen($limit)took the oldest$limit, which once the replies outnumbered the limit were the same replies on every run. One production mailbox held 375 unseen messages, 354 of them replies and 3 reports; a run with a limit of 100 read the oldest 100 and found none. It now takes the newest$limit, still read oldest first.
Documentation¶
- The Email Guide says that non-report mail in the bounce mailbox stays unread and needs a person,
and that
--limittakes the newest messages.
An organisation's roles, written and checked for a fraction of the queries¶
Profiled in an application's API tests, an organisation's team roles took about half of every
test: one call to allowMany() per role, each a DELETE and an INSERT per grant, thousands
of catalogue queries asking whether the same tables existed, and a webhook lookup for every
change with nobody subscribed. Production pays the same on every invitation, role change and
erased organisation.
Added¶
QueryBuilder::insertMany($rows): one multi-rowINSERTper 500 rows. A row whose columns differ from the first row's is refused before anything is written.SchemaBuilder::forgetWhatExists(?Database $db = null), for DDL run through another connection in the same process.
Changed¶
hasTable()andhasColumn()remember what they found, per connection, for the life of the process. A missing table is never remembered, so one a migration creates is seen at once. The builder's DDL, anyDROP/ALTER/RENAMEthrough the same connection and the migration runner forget the answers. The Upgrade Guide has the row, for long-running workers.Permissions::allowMany()in theauthserverstore is oneDELETEand oneINSERT, whatever it grants.WebhookService::queuePermissionsChanged()asks first whether anything is subscribed topermissions_changed, in one query. With nothing listening it does not look for a role's holders or their applications at all.
A controller segment reaches its class in any case¶
Fixed¶
/twofactorauth/backupanswered 404 or the page, depending on what the request had loaded first. A controller is found byucfirst()of the segment,Twofactorauth. PHP's class names ignore case, so that worked onceTwoFactorAuthwas loaded, but before that the autoloader looked forTwofactorauth.php, which a case-sensitive filesystem does not have.Application::getController()now tries the exact name in the area, the application and the framework first, as before. Only when none has it does it search the autoloader's class map and PSR-4 directories for the name in another case. The segment is compared with names that exist and never becomes part of a path.
Documentation¶
- The Routing Guide says which class a controller segment reaches, and in what order.
The auth screens, finished: the theme's colour, closed registration, the account overview and organisation screens in Greek¶
Found by an authorization server that deleted its copies of the framework's auth views and looked at what was left.
Fixed¶
- The brand colour overrode the theme. With
auth_brand_primary_colorunset, the sign-in, password, second-factor and approval screens still set--color-primaryto#2563eb, so a daisyUI theme's own primary, and a dark theme's, became hard-coded blue.Brand::current()gainsprimary_color_set, and the views apply the colour only when it is set. - The sso and OAuth2 fallback screens sent an end user to the administration (
Dashboard). They link to the account now. The sso register button shows only while registration is open. - A closed registration showed an empty card, and its error could appear twice. It shows one notice.
- The account overview was English only. Every phrase of
dashboard/dashboardgoes throught()in the three themes. - The organisation screens had no account layout: no sidebar, breadcrumb or flash messages, and a text box where the role picker belongs.
- The scaffolded
Logincontroller'spostLoginTarget()skipped the parent, so the page the second step was to return to stayed in the session and a later sign-in could land on it. - The notifications' phrases split over two literals could not be translated; each is one key.
Added¶
- The Greek catalogue has 576 phrases: the password toggle, the security and sign-in-code
emails, the messages inbox, the organisation screens, the cookie banner, the account overview
and the scheduled tasks of a deleted account.
Allowreads «Έγκριση»,Scopes:«Δικαιώματα:»; the account's navigation uses a newOverviewkey («Επισκόπηση») and the administration keepsDashboard(«Πίνακας ελέγχου»).FrameworkGreekCatalogueTestcovers the new screens. - The logo's size is
--pf-auth-logo-max-width/--pf-auth-logo-max-height(220px / 64px), on apf-auth-logoclass, so a wider logo needs no copied partial. - The second-factor screen carries the link back to the site, as the other public screens do.
Documentation¶
- The AuthServer Setup Guide covers the colour, the logo variables and closed registration; the Internationalization Guide what the catalogue covers.
The page cache stores no page that prints who its visitor is¶
Fixed¶
- A page that printed the visitor's id could be stored and served to everybody. The tracking
cookies (
visitorid,lastseen) may go with a stored page, so the response that minted a new visitor id, very often a bot's first request, was storable even when its body printed that id. In one application a footer printed it for a script, and 797 visit ids were found shared by 3,054 distinct visitors in a day.store()now refuses a body that contains the session'svisitoridor the value of a tracking cookie the same response sets.
Documentation¶
- The Page Cache Guide says that nothing about the visitor may be in a stored page, and how to
count the visitors who only ever receive cached pages: a hit records no visit, so either a
beacon to an uncached
POST, or tracking ahead of the cache at the cost of a session per hit.