8 September 2026¶
9 changes:
- Registering a redirect URI is recommended, not required
- An array as a cache id put every such call in one entry
- Compression was making five hypertables bigger, and two migrations could not alter one
- A failed save answered the customer their own database schema
- A mistyped API path answered 500 with a trace naming the signed-in user
- One client, three environments:
callbackholds a list, and the CSP stops enforcing - Moving the ledger replayed 91 already-applied migrations; neither entry point will again
- Two of the three developer tools could not be given to a person
- A breaking change had nowhere to be recorded, so it was recorded as a fix
Registering a redirect URI is recommended, not required¶
Yesterday's redirect_uri validation went further than the report asked and further than
it should have: a client with an empty callback was refused outright, which would
have stopped authorization requests that work today on every installation whose column is
empty.
That was not in the filing. The filing was explicit about the opposite, in the sentence describing how the reporting application had resolved the same question:
a client with a registered callback contributes only that origin, a client with none contributes what it asked for — which is what the OAuth layer already permits
And the reasoning behind it is the part worth keeping: whether a client has a callback on file is the operator's decision about their own clients, the way it is on every large authorization server. Registration is what buys exact-match protection; an operator who has not made one has not asked the framework to refuse their traffic on their behalf.
Fixed¶
-
A client with nothing registered is no longer refused. It works exactly as it did before. The condition is written to
oauth.logonce per request, carrying the recommendation and naming the client, so the registration can be filled in deliberately rather than under a login that has stopped working. -
The
!is withdrawn. There is nothing to check before upgrading, and yesterday's "What breaks in subclasses" section now says so — somebody reading it would otherwise run a SQL query for a break that does not exist.
A registration that disagrees with the request is still refused, exactly as before. That is the registration doing its job, and every near miss a prefix or host comparison would accept is still a test.
And what keeps that from being a hole¶
An unregistered client gets no form-action widening. form-action 'self' has been
accidentally preventing such a client's delivery all along — badly, at the cost of blocking
every legitimate login that goes through the sign-in form, but preventing it. Widening the
policy for a destination no registration vouches for would convert a broken feature into a
working attack, which is the ordering problem the original report was explicit about.
The distinction, because it decides the design: the CSP is not being used to block a redirect the OAuth layer permits. It is simply not being opened for one the OAuth layer cannot vouch for. That is also the practical argument for registering — it is what makes an SSO login through the login form complete.
Documentation¶
-
Pramnos_AuthServer_Integration_Guide.md— The redirect URI must be registered is now Register your redirect URI — recommended, and matched exactly, and says what registering buys rather than what omitting it costs you in refusals: exact-match protection, and theform-actionwidening that lets a form-based sign-in finish. -
It also gains Two ways to stop
form-actioncancelling an SSO login — a table comparing registering the callback against listing the origins undercsp: form-actioninapp.php, by scope, by what else each buys, and by whether it needs a deploy.
Worth spelling out because "registration is optional" reads as "the CSP problem is
gone", and for a client with nothing registered it is not: no registration means no
widening, so that flow stays cancelled exactly as it was. The app.php route is the
other half of the answer and is not a workaround — it is the extension point the
original report asked for, and an application is entitled to widen its own policy. It
simply checks nothing: the origin is trusted because somebody wrote it down.
An array as a cache id put every such call in one entry¶
Reported from an installation as PHP Warning: Array to string conversion at
Cache.php:1114, 38,081 times in a day, at a rate unchanged across the reporter's own
deploys — so not caused by anything they had just shipped, and worth a separate look.
load(), save(), delete() and increment() take $id untyped, so an array travelled
all the way to the concatenation that builds the cache name, and PHP wrote the literal
Array into it.
The warning was the smaller half¶
Every array-keyed call produced the same name — <category>_Array.<ext> — so they all
shared one entry, and a load() could return whatever another caller had saved under a
different array. Thirty-eight thousand reads and writes of one key, which is a correctness
bug wearing a warning's clothes.
And why it appeared when redis was switched on¶
It did not start then. load() returns at its second line when $this->caching is false,
before any name is generated — so with no cache configured the array never reached the
concatenation. Turning the store on made an existing call path reachable. The reporter's
instinct that the timing was suspicious and the cause was elsewhere was right on both
counts.
Fixed¶
- A non-string id is normalised at the one place all four methods go through. Distinct
values now get distinct keys, so the calls are at least correct while the caller is being
fixed. Strings pass through untouched, and
int,floatandboolkeep exactly the names they have always produced — an upgrade must not orphan an installation's existing entries.
A string type declaration is not available as a fix: $id is public API on four
overridable methods, so adding one is a fatal at class load for any application that
overrides them (rule 6), and it would turn a wrong cache entry into a broken page for
whoever is passing the array.
- The log names the caller. All 38,081 lines named a line inside the framework, which says where the conversion happened and nothing about who asked for it — and every caller in the framework passes a string, so the report was unanswerable from inside. It now writes one line per call site, with the file and line that passed the array:
Cache id is a array, not a string, from /var/www/app/Controllers/Foo.php:212
(category "views"). It is hashed into a stable key, but until it is fixed there…
Once per call site per process, because 38,081 identical lines is the shape of the problem and must not be the shape of the answer.
An id that will not serialize() — a closure inside the array — falls back to its type
name and collides exactly as before, deliberately: there is no stable name to give it, and
a shared cache entry is better than a request that dies inside the cache layer. The log
line has already named the caller by then.
Documentation¶
Pramnos_Cache_Guide.mdgains The id is a string, and the framework says so if yours is not under Basic Usage — why the parameter is untyped, which conversions are safe, what an array used to do, and the log line to grep for.
Compression was making five hypertables bigger, and two migrations could not alter one¶
Reported as two framework migrations failing on authserver.data_processing_records, a
compressed hypertable — pre-existing, and wanting the decision the reference application
had taken for its own alerthistory, where compression had been measured to make the
hypertable larger.
Both halves turned out to be worse than reported.
The migrations: fixed¶
TimescaleDB refuses outright, and a failing ALTER aborts the whole batch:
ERROR: operation not supported on hypertables that have compressed data
HINT: Decompress the data before retrying the operation.
So on any installation old enough to have compressed a chunk of that table, two migrations
took unrelated migrations down with them on every migrate — an entirely
framework-internal contradiction, since the framework both compresses the table and tries
to add a foreign key to it.
SchemaBuilder::hasCompressedChunks(), and both migrations skip the key with a stated reason instead of raising. Skipping is recoverable and a broken batch is not, which is the decision that migration already makes for five other reasons — a missing column, a missing table, orphaned rows.
Not decompressed: decompressing a production hypertable inside a migration is minutes of maintenance mode on a table nobody asked to touch, and this repository has a measurement of what that costs — thirty to sixty unrecorded meter readings on one installation.
A foreign key and compression coexist once the key is there, which is what makes skipping the right answer rather than a capitulation: a fresh installation adds it while the table is empty and compresses afterwards, and the cascade still fires through a compressed chunk. Both verified on TimescaleDB 2.19.3, and both are tests.
The compression: five layouts were wrong, not one¶
data_processing_records declared a policy and no segmentby. Since the same could be
true elsewhere, all of them were measured — and four more were in the same state, every one
of them growing:
| table | declared | quiet | busy | now | quiet | busy |
|---|---|---|---|---|---|---|
twofactor_attempts |
(none) | 0.79 | 0.69 | success |
1.49 | 27.66 |
user_activity_log |
(none) | 0.78 | 0.72 | action |
1.34 | 44.80 |
user_consents |
(none) | 0.78 | 0.76 | consent_type |
1.56 | 46.43 |
gdpr_requests |
(none) | 0.73 | 0.67 | status |
1.51 | 38.39 |
data_processing_records |
(none) | — | — | operation |
1.58 | 62.59 |
A ratio below 1.00 means compression made the table bigger. It got worse at volume, not
better — there is no size an installation grows into. And userid, measured on four of
them, lost on every one: it is the column these tables are queried by, which is what makes
it the tempting answer, but twenty thousand distinct values make segments of a few rows,
which is what no segment key already amounts to.
orderby is declared beside each one, and that is not decoration: left to the default,
TimescaleDB picks an ordering that includes the segment column and then refuses to have it
in both.
tokenactions, changelog, changelog_events and application_stats are unchanged.
Their layouts were chosen on measurements that include the query axis, which this sweep
does not take — a segmentby column is also cheap to filter on, which is why tokenactions
keeps urlid despite a slightly worse ratio. A layout settled by a better measurement is
not re-argued from a narrower one.
And one where the answer was to drop compression instead¶
Fixing the segment key is right for a table that grows. authserver.gdpr_requests does
not: its only writer is the GDPR controller, one row per request, so its volume is bounded
by people filing requests — tens to hundreds a year, thousands across its seven-year
retention. At 5,000 rows the whole table is 5.56 MB and the best layout saves 1.86 MB.
Against which, compression on that table had already cost the framework two migrations:
2026_08_10_000001 renames notes to processing_notes and adds a foreign key, and both
are refused once its chunks age past a year. 1.86 MB is not worth a table the framework
cannot alter.
So it declares retention and no compression. Retention is unaffected — it drops whole
chunks and needs no compression to do it. Existing installations keep the policy they have:
apply() only ever adds one, and does nothing when compress_after is null.
Four of the five foreign keys those migrations add target compressed hypertables
(user_consents, data_processing_records, gdpr_requests, user_activity_log), which is
the wider shape of the reported failure: on an aged installation four of five were being
skipped. Three keep compression because they grow and it earns 27–63×; the fourth is above.
Added¶
-
tests/Benchmarks/hypertable_compression_sweep.php, which builds each table with its own migration and reads its columns back frominformation_schema. A first version wrote the DDL out by hand and every shape was wrong —actionis notactivity,consent_typeis notappid— and it produced a number that contradictedtokenactions_compression.phpand would have justified changing a layout that measurement had already settled. A benchmark whose schema is a guess measures a table nobody has. -
A test for the rule rather than the five instances of it: a registry entry with
compress_aftermust declare asegmentby, and it must not be a per-row identifier. A sixth is now a red test instead of a table that quietly doubles.
What an existing installation has to do¶
Nothing, to be safe. The migrations stop aborting on their own.
To reclaim the space, the new layout reaches new chunks only — existing compressed
chunks keep the layout they were packed with. timescale:ensure reports the drift;
decompressing and recompressing chunk by chunk is an operator's decision, and the headroom
needed is the size of the largest chunk.
Documentation¶
Pramnos_Hypertable_Guide.md— Choosingsegmentbygains And the four that had no segment key at all, with the full measurement and the three things worth taking from it beyond the values, plus And one where the answer is to drop compression instead and the two-by-two that decides which it is: does the table grow, and will its schema ever change.
A failed save answered the customer their own database schema¶
«μάλιστα, μου γυρνάς και την SQL σου»
Model::_save() threw the driver's message as a plain \Exception. On PostgreSQL a
unique-constraint violation reads:
0:ERROR: duplicate key value violates unique constraint "idx_usertokens_token_lookup"
DETAIL: Key (token_lookup)=(35153ba8…) already exists.
::: SQL QUERY:
INSERT INTO public.usertokens ("userid", "tokentype", … ) VALUES ($1, … $21) RETURNING tokenid
The table, every column, the index name, and the key that collided.
An authorization server had a catch-all in its token endpoint that answered
error_description with the exception message — correctly, most of the time, because
that catch is also where the endpoint raises its own refusals: Unsupported grant type,
Missing client_id. Those messages are the response; the client acts on them. So a
constraint violation arrived at the same place and went out the same way.
Why the consumer could not fix it¶
There was nothing to catch. \Exception is what a hand-written refusal throws and what a
failed insert threw. Masking the catch-all was the first attempt and it broke eight tests,
all legitimate: it turned Unsupported grant type into something went wrong. What shipped
there was a heuristic on the message text — a guess about text in front of a decision about
trust.
Fixed¶
-
Model::_save()throwsQueryException. The type is the whole of the ask: twocatchblocks, each correct in one line. -
QueryException::getMessage()is now safe to print, and the detail moved togetDriverMessage(),getQuery()andgetDetail(). That applies to every site that raises one, not only the new one — the class was already thrown from four places with the driver's text as its message, so they leaked the same way and nobody had reported it yet.
A call site that can say something useful without saying what, does: the insert path raises «Could not save App\Models\Token.» — a class name, which is what an application calls a thing, not what its schema does.
Model::getPaginated()put$qb->toSql()in an exception message, so anything that rendered the exception rendered the statement — and aWHEREclause is made of whatever the request asked for. It goes ingetQuery()now. The same method also re-wrapped every throwable into a plain\Exception, discarding the type it had just been given; aQueryExceptionis re-thrown as itself.
What breaks¶
Nothing that is caught — QueryException extends \RuntimeException, so an existing
catch (\Exception) still catches it and an application that handled the old plain
\Exception keeps working.
What changes is what the message says. If you log $e->getMessage(), log
$e->getDetail() — otherwise the diagnosis is a short sentence. Three of the framework's
own tests were doing exactly that and have been moved to getDetail(); the assertions are
worth reading, because they were pinning the leak.
Documentation¶
Pramnos_Database_API_Guide.md— the strict-mode section gainsgetMessage()is safe to print. The driver's text is not., with a table of the four accessors and which of them may reach a response.
A mistyped API path answered 500 with a trace naming the signed-in user¶
POST /api/1.09/account/logout — a typo for 1.0. Application::getController() appended
the request URI and $_SESSION['user']->username to its exception message, to help whoever
read the log, and it did help.
But an exception message is not only read by that person, and where it goes next is the
caller's choice. Every unhandled path puts it somewhere it should not be: an application
that lets it escape renders a PHP error page carrying the username, a debug toolbar or an
exception reporter forwards it by design, display_errors on a staging host turns it into a
page. A mistyped path is also the shape of a path being probed, so the audience for that
message is not always a colleague.
Fixed¶
-
The context goes to the log, not into the message.
getController()writes the URI and the user to acontrollernotfoundlog and throws the controller name alone. Nothing is lost: the context is also on the exception, ingetContext(), for a handler that has somewhere safe to put it. -
Api::exec()no longer answers 404 to anythinggetController()raises.getController()does more than look up a class name — it instantiates one — so the broadcatch (\Exception)also covered a constructor that throws: a missing dependency, a failed database connection, a bad configuration read at construction time. All of those became{"status":404,"error":"EndpointNotFound"}.
That is worse than the fatal it replaced, and worse in the way that matters: a 500 gets looked at, and a 404 on a path the client mistyped gets ignored. An endpoint that had broken itself reported as an endpoint that does not exist, indefinitely — and that path did not log either. It logs now.
Added¶
Pramnos\Application\ControllerNotFoundException, which is what makes both of the above stop being judgement calls: the type says what happened, so the message no longer has to and the catch no longer has to guess.
One consumer had already narrowed its own copy with
strpos($e->getMessage(), 'Cannot find controller:') !== 0 — the right shape and the
wrong mechanism, brittle because there was no type. It extends \Exception, so an
existing catch (\Exception) still catches it.
Two tests were pinning the exposure¶
ApplicationTest asserted, explicitly, that the message must include the request URI
and must include the session username — with docblocks saying so. That is how it
survived: it was covered, so it looked deliberate. It was deliberate; what it was not was
safe. Both assertions are inverted, and both now also assert the context is still reachable,
because a fix that merely dropped the information would have traded an exposure for a blind
spot.
Documentation¶
Pramnos_API_Guide.mdgains The two «not found» codes, and which is which —EndpointNotFoundfor a route that does not exist againstNotFoundfor a record that does not, why answering the first for a broken endpoint was worse than a 500, and the line an application that has overriddenexec()needs.
One client, three environments: callback holds a list, and the CSP stops enforcing¶
A customer's login worked in production and not from localhost. «When I log in from localhost it does not redirect me back», with nothing server-side to see, because the refusal happens in the browser.
Two faults, and the second is mine from yesterday.
applications.callback held one value¶
The same client is developed on http://localhost:3000, tested on a staging host and run in
production, and all three belong to one registration. A field read as a single URI names
one environment and refuses the others.
-
It holds a list now, and the separator is permissive because a human types this field: commas, spaces, tabs and newlines all separate. A parser insisting on one separator silently drops a callback, which is a registration wrong in the direction that locks people out.
scopein the same table already worked this way. A single value is a list of one, so nothing registered before reads differently. -
A scheme that is only ever script is refused —
javascript:,data:,vbscript:,file:,blob:,about:. Not restricted tohttp(s), becausemyapp://oauth/callbackis a real registration for a native client.javascript://x/%0aalert(1)satisfies every structural rule a URL parser applies, and this value reaches aLocationheader and a CSPform-actionsource. Refused on the request as well as in the registration, because a client with nothing registered has no registration to check. -
And a piece has to look like a callback, which the refused-scheme test found rather than reading did: a URI may contain a comma, so splitting on commas cuts
data:text/html,<script>alert(1)</script>in half — and the tail had no scheme to refuse and was surviving as a registration. A registration is now either absolute with a scheme or a path. -
2026_09_08_000001_widen_applications_callback, because on a database whoseapplicationstable predates the migration systemcreate_applications_tableisSkipped (cutoff)and itstextnever applied — the column isvarchar(255), and three long callbacks is about 190 characters.
The consuming application wrote this migration and withdrew it: PostgreSQL refuses to
alter a column two framework views select from, so widening from outside means carrying
copies of framework view definitions and keeping them in step by hand for ever. The
framework owns the create-table and the views, so it is the only place the three move
together. It drops the dependents with CASCADE and re-runs the views migration, which
drops and recreates all eleven from their own definitions — so there is still exactly one
definition of every view.
Until it runs, the admin screen refuses an overflow with a message naming the ceiling and telling you to run migrations, rather than letting the driver answer «value too long for type character varying(255)».
And the CSP stopped enforcing what the OAuth layer does not¶
Yesterday's change widened form-action only for a registered callback, on the
reasoning that the two layers should agree rather than one compensating for the other. It
had the agreement backwards, and it is what caused the outage.
form-action can only answer may the form post toward the place this request names, not
should this request be allowed. The endpoint accepts an unregistered client's
redirect_uri — deliberately, since registration is optional — so refusing in the policy
added no security, only a second place to be inconsistent. It was not even the protection it
looked like: form-action governs form submissions, so a user who already holds a session
takes GET /oauth/authorize → 302 → client with no form in it, and the code is delivered
regardless.
What the gate stopped was the legitimate half. A mismatch against the registration is worth a log line, never a refusal, while the layer that could refuse does not.
The filing put it better than the fix had:
It answers "may the form post toward the place this request names", not "should this request be allowed", because it cannot answer the second.
Two of yesterday's tests asserted the gate, one of them titled «and it gets no form-action
widening, so nothing new becomes possible». Both are inverted, and both say why — a test
that locks in the wrong design is worse than no test, because the next person reads it as
the decision.
Documentation¶
Pramnos_AuthServer_Integration_Guide.md— One registration, several environments with the three separator styles, the%2Crule, the refused schemes and the ceiling on older installations; and a warning box on why a CSP cannot be the enforcement point for a decision the layer below it does not make.
Moving the ledger replayed 91 already-applied migrations; neither entry point will again¶
Both migration systems write to schemaversion and key it differently. The legacy path
stores a migration's $version (0.088); the runner stores its slug (migration0088).
Nothing carried the history across, so the runner found none of migration0011 …
migration0125 and did exactly what it is supposed to do with migrations that are not
recorded:
07-09-2026 05:32:45 batch 8 migration0147 (the deploy that was intended)
07-09-2026 07:35:57 batch 10 91 migrations, from migration0011 onwards
07-09-2026 07:36:33 batch 10 migration0091 → add_compression_policy(…, '3 months')
Ninety-one already-applied migrations ran against a live production database in about fifty seconds, and the ledger recorded every one as a success.
Why most of it was survivable, and which part was not¶
Re-running old DDL mostly fails on arrival — CREATE TABLE on an existing table errors, and
addQuery() is tolerant so the rest of the migration continues. The ledger filled with
lines like 23 of 80 statements failed (12 look like work already done). That accident is
what saved it.
The dangerous ones are the idempotent ones, because they succeed. migration0091
re-added a compression policy that migration0147 had removed two hours earlier with
measurements behind it — compression grew that table 40% and cost 79.8 ms on the
installation's busiest query. The replay stopped at migration0125, so 0147 was never
reached to clean up after 0091. The policy then recompressed 56 chunks, and the ledger still
read migration0147 | result=1 | error_message=NULL, which is true: the migration did
succeed, and was undone afterwards by a replay of an earlier one. It was found by asking the
database what state it was in rather than asking the ledger what it had done.
It happened again two days later, through the other entry point¶
A single web request reached Application::exec() → checkversion() → upgrade() — the
legacy path, which keys the ledger by $version — found no 0.137 … 0.147 rows and
replayed eleven more. One of them decompresses a 70-chunk hypertable, and it did: in a
client request, behind the maintenance mode runMigration() correctly raises, at a measured
cost of roughly thirty to sixty meter readings that were never recorded. migrate:status
reported "0 pending" throughout, because it reads slugs.
It also defeated a deliberate guard, which is the part worth keeping in view: the
application had set autoExecute = false on its own copy of that decompression, precisely
to keep it out of a client request. The work happened in a client request anyway, through a
different migration, on the path that does not consult the runner at all.
Fixed¶
-
migrateadopts a version-keyed history automatically, before deciding anything is pending.migrate:adopt-legacycould always do this, and that was the problem: it is a command somebody has to know exists, and the person who needs it has just upgraded and does not yet know anything is wrong. It costs one read of a table that is about to be read anyway, and on an installation that never used the legacy path it matches nothing. -
A whole history is refused on a database that is not new — ten or more pending migrations, an empty ledger, and tables already in the schema. A runner that finds zero of ninety-one recorded is not looking at a fresh database; a fresh database has no tables either, and this one had 407 GB of them.
The message carries the three ways out, because a guard with no way past it is one people
route around by deleting rows or pinning an old version: migrate:adopt-legacy --dry-run,
migration_cutoff, or migrate --adopt-baseline. That last is its own option and not a
second meaning for --force, which means include autorun = false migrations — an
operator reaching for one must not silently get the other, least of all this one.
- At both entry points, because the filing was explicit that fixing one would not have
been enough, and the second replay proved it.
Application::upgrade()logs and stops rather than auto-running a history inside a web request. Below the threshold it behaves exactly as before: deploying an application with one or two new migrations still needs no shell.
Why a threshold rather than "the ledger is empty"¶
The first version refused on an empty ledger alone, and it fired on the framework's own test suite — 39 tables, a truncated ledger, two probe migrations. That is a true positive in shape and a false alarm in substance, and it pointed at the real rule: the state being refused is a whole history replaying, not an empty ledger. Ten, because the incident was ninety-one and the framework alone ships over a hundred; below it, a message costs more than it saves.
The third ask already existed¶
MigrationRunner::RESULT_RAN_WITH_ERRORS has been the distinct partially-applied state for
a while — set when failedStatementSummary() is non-empty, rendered by migrate:status as
Ran with errors. The installation that reported this predates it. The legacy ledger has
one column and cannot hold a result at all, so that path writes the detail to the upgrades
log instead.
Documentation¶
Pramnos_Migration_Guide.md— Coming from the legacy version ledger now leads with the automatic adoption and what it prevents, and gains A whole history is refused on a database that is not new with a table of the three ways past it and the reason the same refusal is on the automatic path.Roadmap.md— the item is removed rather than marked done. That page says it tracks what is not yet done, and a fixed entry sitting on it is how a roadmap becomes a changelog nobody trusts. The reasoning lives here, which is what this corpus is for.
Two of the three developer tools could not be given to a person¶
/devpanel honoured userids. /adminer and /debugbar had a usertype floor and nothing
else, so no configuration meant this account and nobody else for either.
A usertype is a role, and on a real installation it is a role granted to other organisations. One production database had:
- 9 accounts at usertype 99, across six different organisations — each able to open a full database client against a live 418 GB database;
- 64 accounts at 90 or above (38 at 90, 17 at 98, 9 at 99) — each able to issue itself a
toolbar grant carrying the query log of a live request, because
grant_min_usertypewas never set and the default is 90.
"May administer their own organisation's data" and "may read the whole database from a browser" are different questions, and a floor answers them with one number. That installation set both floors to 100 and turned two of the three tools off, because off was the only reachable state narrower than nine organisations. The account that should have had them got them through shell access instead, which is a property of that installation and not a design.
The asymmetry was known and half-closed. allowedOnAnyDeployment() cites Adminer as the
arrangement it is copying:
"This is the arrangement Adminer already has, in the more dangerous of the two tools."
The panel gained the narrower gate. The tool its own docblock calls more dangerous did not.
Added¶
-
Pramnos\Application\DeveloperAccess, which all three gates now call. One resolver rather than a fourth copy of the same three ideas — three gates each reading their own subset is how this drifted, and a fourth tool would drift the same way. -
Each tool gains a
usertypesand auseridslist, and an unset list falls back to the panel's. Sodevpanel.useridsreaches all three and there is no third list to forget, whileadminer_useridsstill makes the database client narrower than the panel.
The fallback is one-way: naming somebody for Adminer does not give them the panel, which browses the database, reads the cache and dumps the container. There is a test for that direction specifically, because it is the one that would be a security bug rather than an inconvenience.
- Every opening of any of the three outside a development environment is logged, naming the tool. The panel already did this and it is the only visible trace on a live server; Adminer opening for a named account deserves it more, not less.
An installation that sets none of the new keys sees no change. Every existing key keeps its name and its meaning, the floors are what they were, and an absent list widens nothing.
What the defaults are not¶
grant_min_usertype stays at 90 while Adminer's stays at 99, and the guide now says why
rather than leaving it to be read as an accident: Adminer reads the whole database, and the
toolbar grant carries the queries, session keys and logs of one request in one
browser for a bounded time. The gradient is deliberate.
It is worth stating because that installation read the 90 as a decision somebody had made
about it. Nobody had — the key was simply never set. An installation that disagrees now has
grant_userids, which is narrower than raising a floor out of reach.
Where a test found the fault, and where one had to be rewritten¶
-
An unregistered tool name inherited the panel's permissions. The first resolver read every key as absent and fell through — to the caller's floor, and then to
devpanel.userids, because the fallback only excludes the panel itself. A typo in a tool name opened a gate, which is not a mistake anybody notices. It fails closed now and logs the name it was asked about. -
And a guard that looked tested was not.
permits()refuses a user id below 1, and the first test for it arranged a zero-id session — whichSession::staticIsLogged()already refuses, since it requiresuid > 1. So removing the guard reddened nothing and the test proved the wrong thing. What reaches it is the suite's own$unittesting_loggedoverride, the one place in this framework where "signed in" is decided without consulting the id — which is exactly why a gate that trusts it must check the id itself.
Documentation¶
Pramnos_DevPanel_Guide.mdgains All three developer tools read the same names — the table of nine keys and three defaults, the one line that gives one person everything, the one-way fallback, and a note on why the two default floors differ. Opening it on a server that is not a development one also stops suggesting9999, sinceDevPanelServiceProvider::bootHttp()warns on every request above 100.
A breaking change had nowhere to be recorded, so it was recorded as a fix¶
A mobile application failed the day before a municipal presentation, on a payload it had been sending unchanged for months. Every batch answered:
Request::decodeBody() had changed on 14 August: (array) json_decode($raw) casts only the
top level, so every element of a nested list arrived as an stdClass; the new decode is
associative, arrays all the way down. The handler reads each element with ->, isset() on
a property of an array is false, and the required-field check failed on element one and
returned before storing anything — the whole batch refused, not the bad element.
No exception, no log line, no failing test: a unit test that hands the controller a
hand-built array of stdClass still passes, because what changed is the decode and not the
handler.
The change was right. The classification was the fault.¶
Associative all the way down is the defensible contract, and a half-cast structure — object elements inside an array — was a genuine bug. Nobody is asking for it back.
But it was filed as ### Fixed: JSON bodies were decoded one level deep, on the
reasoning that it was "a regression rather than a feature that never worked". That is true
of the endpoint it was found on — a standalone script that had called
json_decode($raw, true) itself — and not of a handler written against what the framework
actually did, which is the majority case and had been working for a long time.
"Breaking" and "fix" are orthogonal labels, and filing under one was excluding an item
from the other by construction. The judgement was already available in the same post, for
documentAssetSource: "the default stays the CDN. Flipping it would break every
application that stopped vendoring those files" — and the 2020 CDN move was documented
retroactively "as the breaking change it was". The same reasoning was there and was not
applied here.
And the mechanism the guide points to had no slot¶
Pramnos_Upgrade_Guide.md opens by describing this incident almost word for word:
"Most upgrade pain is behavioural, not signature-level: code still compiles and autoloads, but a response envelope, default, or generated asset changes shape. The per-version Breaking changes tables below list exactly those cases."
Except the tables are strictly version-to-version and cover only tagged releases:
| v1.2 tagged | 2026-07-12 |
| this change | 2026-08-14 — a month after the tag |
| latest breaking-changes table | v1.1 → v1.2 |
| a "since v1.2" section | did not exist |
| daily posts since the tag | 41, 37,907 lines |
Applications track dev-main — this framework's own instructions are to composer
update — so an upgrade crosses everything since the tag, and the documented mechanism had
no place for any of it.
Added¶
## Since v1.2 — unreleasedin the Upgrade Guide, with the five behaviour changes since the tag: the JSON body decode,QueryException's message, the typedControllerNotFoundException, the OAuth2redirect_uriregistration, and the migration refusals. Three columns, and the third is deliberately grep-able — "any handler that reads an element of a JSON body with->now receives an array", not a paragraph.
It becomes the v1.2 → v1.3 section when the tag is cut, assembled as changes land
rather than reconstructed from a month of posts.
-
CLAUDE.md rule 6 now requires the row, in the same commit as the change. Rule 1 already required the guide and the changelog post; what was missing was the third place, and the reason it is third is that signatures are the easy half.
-
A test, because the failure mode is the section quietly not being there — after a tag is cut and the old one renamed, or after somebody trims it. Neither is visible in the diff of the change that needed it, which is the same reason
DocsRetrievabilityTestexists. It asserts the section is top-level, that it has rows, and that every row says what to do.
It deliberately does not check that a particular change is listed. Which changes break is a judgement, and a test that tried to make it would be either a list maintained twice or a heuristic on headings — the third of the filing's three asks, and the one that stops being necessary once the table is kept by rule.
Documentation¶
Request::decodeBody()'s docblock now says the change was breaking as well as correct, and why the "regression rather than a feature that never worked" reading was true only of the endpoint it was found on.