6 September 2026¶
12 changes:
- A token issued by the framework's own OAuth2 server had no usable scopes
- Seven more readers of
scope, and a fourth shape the parser did not take - A JWT test that reddened the suite on a second boundary
- One foreign
Databaseclass took the whole MCP server with it Scopessaid to subclass it, and then never asked the subclass- The migrations ledger was created without the prefix and checked with it
db-inspectwithheld the one number its docblock promised- The debug toolbar can be turned on from a screen, not only from a shell
- The DevPanel opens on a live server for people you name, not for
APP_DEBUG - Adminer answers while the site is in maintenance
- A redis blip killed queue workers, and the ledger under-reported it threefold
- An optional credential would have been stored in plaintext with no key
A token issued by the framework's own OAuth2 server had no usable scopes¶
A consuming application mounted POST /mcp, issued a token through this framework's own
OAuth2 token endpoint, gave it four scopes, and got {"tools":[]} back. The endpoint
authenticated the caller and said nothing about why the list was empty.
The token was fine. usertokens.scope held mcp mcp:diagnostics mcp:logs — space-separated,
because RFC 6749 §3.3 says so and
because AccessTokenRepository writes it that way — and that was the one shape
Token::load() could not parse. It matched neither the JSON branch nor the comma branch,
so the fallback wrapped the whole string in an array and a token with four scopes arrived
holding one element that matched nothing anywhere.
McpController::scopesOf() had the correct parse. It was unreachable: Token normalises the
column in its constructor, so by the time a token reaches the controller it is always an
array, and the explode(' ', …) branch could never run.
Fixed¶
-
Token::parseScopes(), one parser, used byToken::load(),McpController::scopesOf()andwhoami. Two readings of one column is how a diagnostic tool comes to report scopes the endpoint did not act on, which is worse than no diagnostic at all. -
Four other shapes were wrong in quieter ways, and they are why this is a parser rather than one more
elseif:Column held Was Now mcp mcp:diagnostics mcp:logs["mcp mcp:diagnostics mcp:logs"]three scopes mcp, mcp:logs["mcp", " mcp:logs"]— with the spacetwo scopes 123123, an int["123"]"mcp""mcp", a stringa list null[null][]A scalar where a list belongs fails every
in_array($needed, $scopes, true)no matter what it holds, and' mcp:logs'with a leading space matches nothing while looking correct in a var_dump. -
Oauth::userinfo()read the column withexplode(' ', …), so a row written as JSON answeredinsufficient_scopefor anopenidgrant it actually had. -
Oauth::introspect()returned the column verbatim. RFC 7662 §2.2 saysscopeis a space-delimited string, so a JSON row handed the client something it could not split. It is normalised on the way out now.
Read three shapes, write one¶
That column holds JSON, comma-separated and space-separated values across installations, and the framework wrote two of them itself. The filing asked which way this should go, and the answer is both:
- Reading accepts all three, permanently. Those rows exist and nobody is migrating somebody else's database.
- Writing emits the standard.
Token::save()andUser::addScopedToken()now write space-separated, which is whatAccessTokenRepositoryhas always written. There is no reason to keep making new rows in a second shape.
Two states that used to look identical¶
The filing also asked whether an authenticated caller with no tools should be told apart from one whose scopes did not parse — the first a configuration, the second a bug.
They are separated structurally now rather than by adding a message: a non-empty column can no longer produce an empty list, because the fallback is a whitespace split and a non-empty string always splits into something. An empty answer therefore means exactly one thing — the column was empty — which is a configuration somebody can be told about, rather than a defect hiding behind a correct-looking refusal.
Project rules¶
CLAUDE.md gained the convention a separate filing asked for: a change that can break a
consuming application takes ! and the scope of what actually changed, and adding an
optional parameter to an overridable public method is not additive — PHP refuses to load
a subclass whose signature no longer matches, so it is a fatal at class load for anybody with
an override. Add a new method instead; where there is genuinely no compatible way, the day's
post carries a What breaks in subclasses section. The
5 September post has been given the one it was missing.
Seven more readers of scope, and a fourth shape the parser did not take¶
Fixing Token::load() this morning fixed one reader of eight. Adopting the parser in two
applications meant auditing every scope read, and the audit came back with the rest.
Fixed¶
Seven sites parsed a scope column themselves — five with explode(' ', …), one with
json_decode(…) ?: explode(…), one with a single-permission fallback. Which shapes an
installation could store therefore still depended on which code path happened to reach the
row first. They all delegate to Token::parseScopes() now:
| Site | Reads | What it did |
|---|---|---|
Auth\Application::getScopes() |
applications.scope |
explode(' ', …) — a client refused grants its own row lists |
OAuth2Middleware |
usertokens.scope |
json_decode(…) ?: explode(…) — see below |
Oauth::hasConsentFor() |
oauth2_user_consents.scope |
explode(' ', …) — a consent that never matches is a consent asked for again on every authorization |
Oauth::recordConsent() |
the same column, merging | two readings of one column, merged into it |
Scopes::areApplicationScopesGranted() |
applications.scope |
whitespace split |
Scopes::resolveInheritedScopes() |
a string\|string[] argument |
whitespace split |
Controller::_auth_normalizePermissions() |
a string\|string[] argument |
explode(' ', …) |
Router::normalizePermissions() |
a string\|string[] argument |
see below |
OAuth2Middleware was a 500, not a refusal. json_decode($scope) ?: explode(' ', $scope)
handles two shapes and crashes on a third: a column holding 123 decodes to the int
123, which is truthy, so $granted became an int and in_array($scope, 123, true) is a
TypeError on PHP 8 — where a scope check belongs.
Router::normalizePermissions() was the original bug in miniature. Any string without a
space became one permission, so 'mcp,mcp:logs' was a permission literally named
mcp,mcp:logs and matched nothing. These are the caller's permissions, so a shape that
does not parse is a refusal of access somebody was granted.
A fourth shape: [profile email]¶
Brackets round a space-separated list. Not JSON, no comma, so the whitespace split yielded
['[profile', 'email]'] — two scopes nobody granted, and a refusal naming both.
The framework already accepted it, in exactly one place.
Scopes::addDefaultScopesToToken() has stripped these brackets with a /^\[(.*)\]$/ since
before the parser existed, and it was the only reader that knew — which is why the canonical
parser did not, and why a value that method accepted was refused everywhere else reading the
same column. That is the previous filing's defect one level up: accept a shape in one reader,
refuse it in another.
The rule moved into parseScopes(), after the JSON attempt rather than before it — ["a","b"]
is bracketed too, and stripping first would leave "a","b" and produce scopes with quote
marks in them. addDefaultScopesToToken() delegates and gained the other shapes for free.
Where the forgiveness stops¶
Worth stating, because the audit made it a question rather than an assumption:
| A column, or a stored token's scope | Token::parseScopes() |
| An OAuth request parameter | space-delimited, as RFC 6749 §3.3 defines |
The forgiveness is for rows written years ago by code that has since been fixed. A request is
a live conversation with a client that can be told it is wrong, so hasInvalidScopes() still
reads the standard form only and reports profile,email as one unknown scope. Pinned in a
test, because it is the kind of line that erodes.
Documentation¶
- Authentication guide — the four shapes and where
each came from, the column-versus-request rule, and why not to
explode(' ', …)a raw column value.
A JWT test that reddened the suite on a second boundary¶
testTheNotBeforeClaimToleratesAClientClockBehindTheServer captured time() before the
login and compared it against an nbf computed from the clock inside login(). When the
second ticked between the two, nbf came out one higher than the assertion allowed and the
suite went red for no defect — roughly one run in a few hundred, which is often enough to be
distrusted and rare enough to be dismissed.
Fixed¶
The window is read on both sides of the act now, so the claim is bracketed by the interval it was actually minted in. That is also the stronger assertion: it pins the backdate at exactly twelve hours rather than at at least twelve.
Every other time-based assertion in the suite already used the two-sided form — this was a single slip rather than a habit, and the audit that found it is in the same commit.
One foreign Database class took the whole MCP server with it¶
registerDefaults() asked $app->database !== null and handed the result to constructors
typed against Pramnos\Database\Database. Those are different questions, and an application
part-way through migrating to this framework — which is most of the audience — answers yes to
the first and no to the second:
TypeError: ListTablesTool::__construct(): Argument #1 ($db) must be of type
Pramnos\Database\Database, App\Database\Database given
It did not cost the three database tools, it cost the server. One call registers all
twenty-one, so the throw landed before anything was added: mcp:call status — a tool that
touches no database — exited 255 with nothing on stdout or stderr, the fatal reaching only
php_error.log. mcp:serve the same, which is the whole feature.
Fixed¶
-
instanceofrather than!== null, which is what the comment one line above already described: "two of them need a database and are skipped without one". A database the tools cannot accept is that case, and the other eighteen work fine without one. -
The silence, separately.
mcp:callnow reports what could not be registered instead of dying, andmcp:servewrites it to stderr — only stderr, because it speaks JSON-RPC on stdout and a diagnostic there is a protocol violation the client reports as something else. Theinstanceoffixes the known cause; this fixes the class of it.
Scopes said to subclass it, and then never asked the subclass¶
The docblock has always been explicit — "Application-specific scopes should extend or
override getScopes() in a subclass" — and the class is built for it: every internal call is
static::, so one override gives the subclass's own callers a consistent view.
Ten sites in the framework then wrote Scopes:: literally, and those are the ones that face
outward: scopes_supported in the four /.well-known/ documents, the consent screen's
descriptions, the OAuth2 ScopeRepository, PublicRegistry, mcp:token, init.
So an installation could define forty-four scopes, publish fifteen, render a consent screen with no description beside the grant a user is being asked for, and have its own identifiers refused as unknown. Reported by an application carrying a hand-maintained copy of the same catalogue in two repositories against one database — the duplication the subclass exists to end and could not.
Added¶
Resolved at boot beside features and personal_data, in the web lifecycle and in the
console — mcp:token validates against the catalogue, and a console answering from the
framework's list would refuse an installation's own scope names. A class that is not a
Scopes subclass is refused with an exception rather than ignored: a misspelt name dropped
quietly is the same failure by a different route.
Replace or merge is the subclass's decision¶
Because it cannot be the framework's — a catalogue is not reliably a superset. The reporting
application's has neither address nor the four mcp* scopes, so "merge" and "replace" are
different answers to what scopes_supported should contain and only one can be right by
default. So the subclass says which, in code:
public static function getScopes(): array { return parent::getScopes() + ['Ours' => [...]]; }
public static function getScopes(): array { return ['Ours' => [...]]; }
Replacing drops openid, profile, email and offline_access unless declared, and the
OAuth2 server needs those to answer an OIDC request. That is asserted in a test rather than
prevented: what the framework can do is make the consequence visible.
The delegation cannot recurse — a subclass calling parent::getScopes() arrives at the base
with static::class equal to the provider and takes the framework branch.
The MCP scopes are appended either way¶
They are derived from what PublicRegistry actually offers rather than declared anywhere, so
a catalogue written before the endpoint existed must not be able to make it ungrantable. A
replacing subclass keeps them, and a token can still be issued for a tool that is registered
and answering.
Not done, and why¶
checkTokenScopes() — are the scopes on this already-issued token still granted to the
application that issued it — stays in the application. It is a real question and a different
one from areApplicationScopesGranted(), but a token outliving its grant is a policy an
installation decides: whether an edited applications.scope row revokes in flight or at the
next issue is not something the framework should answer for everybody. The subclass keeps it,
which is now a supported place for it to live.
Documentation¶
- Authentication guide — the config key, the ten outward-facing call sites in a table, and replace-versus-merge written as two code samples.
- MCP guide — why the database tools are gated on
instanceof.
The migrations ledger was created without the prefix and checked with it¶
MigrationRunner wrote the history table's name verbatim in every raw statement and
resolved it in exactly one place — hasColumn(), which goes through
SchemaBuilder::resolveTable() and applies the installation's prefix. On a site that has
one, those are two different tables, and both failures happened together:
pramnos_schemaversion 2026-08-24 09:31:10 the real one
schemaversion 2026-09-06 11:14:22 created by migrate:status
CREATE TABLE IF NOT EXISTS made the second, bare table — so migrate:status, a
read-only command, created a table. Then hasColumn() inspected the prefixed one,
found scope missing, and the ALTER added it to the bare one, which already had it:
Every later call failed the same way. An asymmetry is not luckier the second time.
Fixed¶
-
One resolved name, in all thirteen places. Twelve raw statements —
CREATE,ALTER, fiveSELECTs,INSERT,DELETE— plus thehasColumn()that was already resolving. The filing found three; the other nine were the same defect, quieter, because a ledger written and read under one wrong name is at least self-consistent. -
The prefixed name is the right one, which also ends a disagreement inside the framework: the legacy
Application::runMigration()path has always written#PREFIX#schemaversion. The two halves of the migration system were pointing at different tables on any prefixed installation.
The dangerous half¶
Simply switching to the prefixed name would have been worse than the bug. An installation that ran the old code has its history in the bare table; a runner that started reading the prefixed name would find nothing, conclude no migration had ever run, and replay every one of them against a schema that already has their changes.
So the first run adopts: when there is no prefixed ledger and a bare one exists, it is renamed. Renamed rather than copied, because two ledgers for one thing is how this started; and only when the target is absent, so a real prefixed ledger is never replaced by the empty stray sitting beside it. Both cases are pinned in tests, and so is the no-prefix case, which is most installations and where nothing changes at all.
The existence check for that decision is raw information_schema / to_regclass rather
than SchemaBuilder::hasTable() — which resolves what it is given, so asking it about the
unprefixed name returns an answer about the prefixed one. The substitution that caused the
bug, applied to the check meant to detect it.
For an installation that has the stray table¶
Nothing to do. Drop it whenever convenient; nothing recreates it.
Documentation¶
- Migration guide — the ledger's name under a prefix, what the two tables were, and what the first run under the fix does about them.
db-inspect withheld the one number its docblock promised¶
The class docblock has said this since the tool shipped this morning:
«How many live tokens have no digest», «are there duplicate settings names», «how many images are under this size» are all counts, and a count exposes nobody.
And the withheld branch reported row_count — the number of result rows, which is 1
for every SELECT count(*). So the number asked for was the one value never returned, and
the note beside it told callers to «narrow the query to aggregates», which is precisely
what did not work.
An integration test pinned it there, asserting rows_withheld and the absence of
rows for SELECT count(*) AS total FROM users — the docblock's own example, written down
as the expected behaviour. A test can only be wrong like that once somebody reads it.
Fixed¶
A projection of nothing but COUNT() is answered in full. Every spelling somebody writes:
count(*), an alias with or without AS, count(distinct …), two counts, a filtered
count, a count over a derived table.
COUNT and not "an aggregate", deliberately¶
MIN(email) and MAX(email) return an address. SUM(salary) and AVG(salary) over a
filter matching one row return that person's. A check that accepted any aggregate would
have handed back exactly what the denial list exists to withhold, behind syntax that reads
as a summary — so the integration test for that case inserts a row with a marker address
and asserts the marker is nowhere in the answer.
Everything else still withholds, including the shapes a lexer should not claim to
understand: count(*) OVER () (per-row output), count(*) + 1, count((SELECT …)), a
WITH clause, and any projection mixing a count with a bare column.
What it does not promise, said out loud¶
A count is still an oracle over a narrow filter: count(*) … WHERE email = '…' answers
whether that person exists. That is not prevented, because preventing it means refusing
WHERE token_lookup IS NULL — the query somebody actually needs, and one of the docblock's
own three examples. This stops rows arriving in bulk; it does not stop a determined
question from somebody who already holds mcp:db_read on a production database.
Documentation¶
- Security guide — the answer table now has three rows,
with why the line is
COUNTand what the oracle limitation is. - MCP guide — the same, beside the tool's refusals.
The debug toolbar can be turned on from a screen, not only from a shell¶
DebugAccess could always do this. The only door was php pramnos debug:token, which
needs a shell on the server — the right door for a deploy box and the wrong one for the
moment somebody is looking at a page that is misbehaving. The round trip is shell, copy,
paste, and the page they were on is gone by the time they get back.
Added¶
/debugbar — two buttons, reachable without the application registering anything,
the way /devpanel is. It issues the same signed token the CLI mints and redeems it
through the same ?_debug= parameter, so a grant is taken up in one place rather than two
that can drift apart, and it redirects to the page you came from with the toolbar on.
| Who | Signed in, user type ≥ 90 by default |
| Configurable | 'debug' => ['grant_min_usertype' => 95] in app.php |
| How long | 15 minutes, 1 hour, 4 hours |
| Recorded | Every grant and revocation to the auth log, with who and for how long |
The DevPanel gets a Debug toolbar tab beside Logs pointing at it — a route of its own rather than an action of the panel, the same arrangement Adminer has. The panel's own contract test caught this immediately ("a tab whose action was never registered is a 404 in the navigation"), so the exemption is named there rather than pattern-matched, and it now also asserts that an exempt tab points at a URL — otherwise "this is not an action" quietly becomes "nobody checks where it goes".
Three decisions worth writing down¶
It is not in the DevPanel, and that is the point. The DevPanel refuses to open unless
APP_DEBUG or DEVELOPMENT says the deployment is a development one, argued at length on
its own controller: it browses the database, reads the cache and dumps the container. The
toolbar is a smaller thing — this request, to the one browser that redeemed a grant — and
its whole reason for existing is the server where APP_DEBUG is off. Behind that lock it
would work only where nobody needs it.
The floor is a user type, not "signed in". The query collector reports the SQL each
request ran, and this framework interpolates values into statements, so the toolbar shows
real column values — personal ones included — for the rows the browsing touches. The
session collector masks password, token, secret; the query log does not.
The guard is in the action, not addActionPermission(). Controller::auth() enforces
action permissions only when $this->user_permissions is non-empty. On an installation
that populates nothing there, a declared permission is skipped in silence — a fine default
for a screen that lists things, and the wrong one for a switch that turns on a query log.
Smaller things that would have been bugs¶
Both actions are POST with a CSRF field, because a state change behind a GET link is a
link somebody else can get you to click. The return URL goes through
DevPanelController::returnUrlFor(), which remembers the referrer in the session — the POST
has this screen as its own referrer — and refuses anything not on this site, so the
redirect is not an open one. The constructor forwards both arguments
getFrameworkController() passes; dropping the second would leave user_permissions empty
for any subclass an application writes.
Documentation¶
- Debugging guide — From a screen, without a shell, under the existing live-server section, with the link to drop in an admin menu.
The DevPanel opens on a live server for people you name, not for APP_DEBUG¶
The environment was the only key: APP_DEBUG or the DEVELOPMENT constant, and nothing
else. That lock is right about what it protects — the panel browses the database, reads the
cache and dumps the container — and wrong as the only key.
APP_DEBUG is not a switch for "let me see the DevPanel here". It also turns on error
display, changes what a page carries, and opens the debug toolbar for every visitor.
Turning all of that on to reach one screen is a much larger statement than anybody means to
make, and the guide's advice was to make it: "the answer is a deploy — APP_DEBUG=1 and a
restart".
Added¶
'devpanel' => [
'production_min_usertype' => 99, // opens it on any deployment
'usertypes' => [95], // these types too, wherever they are
'userids' => [7], // and these people, whatever their type
],
Checked before the environment, because that is what it overrides. The development route
is untouched: APP_DEBUG plus min_usertype still works exactly as it did.
The three widen and never narrow — any one suffices. To restrict the panel to named people
and nobody else, raise production_min_usertype out of reach and list the ids. That is the
configuration somebody will actually want and it is not obvious from the keys, so it has a
test of its own.
Why 99 by default, and why that is smaller than it looks¶
Adminer::ROOT_USERTYPE is already 99 and already opens a full database client on a live
server, configurable the same way. So an owner could do that and not open the panel whose
tab links to it. That was an inconsistency rather than a protection, and the panel is strictly
less powerful than the tool already reachable from it.
There is still no setting that opens this. What changed is that the alternative to
APP_DEBUG=1 is no longer "nothing" — it is a name in app.php, which is also a deploy and a
much narrower one.
Every opening outside a development environment goes to the auth log with the user, their
type and the address. On a development machine the environment is the trace; on a live one
nothing else would be. Logged only when the environment would not have allowed it anyway — a
line per tab on a dev machine is a log nobody reads.
Fixed on the way past¶
guardUserType() tested $user === null, and getCurrentUser() answers false for an
anonymous visitor. Reading a property off that lands on the right answer — 0, refused — and
leaves attempt to read property on bool in the log of a route where a log entry is the only
trace of somebody trying the door. Adminer::mayOpen() documents the same slip and had
already fixed it one file over.
The test that would not have failed¶
The first version of this class tested allowedOnAnyDeployment() directly, and removing the
two lines that call it from guardAccess() left every test green: the method correct,
complete, and reached by nothing. There is a test driving guardAccess() now, and the
mutation turns it red.
Documentation¶
- DevPanel guide — the two routes in as a table, the restrict-to-a-list recipe, and what is logged.
Adminer answers while the site is in maintenance¶
The application's constructor answers the maintenance page and stops, before a route is resolved. Right for traffic, wrong for the person who raised the flag: maintenance is usually up because a schema is in flux, and the tool for inspecting a half-finished migration was unreachable for exactly the duration of one.
Added¶
['adminer'] by default. Naming the key replaces the default rather than adding to it,
which is the surprising half and so has a test of its own.
It grants one thing: that the application boots¶
Every route's own authorisation still runs. For Adminer that is signed in plus
usertype >= 99, or a development environment plus the DevPanel's floor — so anybody else
gets the 404 that route gives everybody, logged as it always was.
That is what makes the loose matching safe. The check runs before beginRequest(), so the
base path is not known and a subdirectory install has to match on any segment rather than
the first. The worst a stray URL can do is boot an application that then refuses it, which is
what an ordinary request does. Segment-wise and not substring, so /adminerator and
/my-adminer-notes still meet the page.
And auto-migrations are held off by the flag regardless — runAutoMigrations() returns early
while it is up — so booting here cannot start the thing maintenance was raised to prevent.
One reordering, and why it is safe¶
The check moved from before setDefines() to just after app.php is read, because that is
where the exempt list becomes knowable. What now runs ahead of it is setDefines() and one
file read — both cheap, neither a request.
Worth being explicit given the history: a filing in August was a fatal on this exact line,
because maintenanceMessage() built its path with DS, a constant setDefines() had not yet
defined. The path is a literal / now, so moving the check later reduces that class of
problem rather than adding to it.
The test that would not have failed¶
Written first as unit tests of the new method, which left the constructor's own condition
unasserted — delete the && !$this->requestIsMaintenanceExempt() and every one stayed green.
Constructing an Application boots a database and a session, so the guard is pinned by
reading the source instead: the three conditions, in order. The mutation turns it red.
Documentation¶
- Framework guide — Paths that still answer while it is up, beside the two flags.
- DevPanel guide — the same, next to Adminer's own gate.
A redis blip killed queue workers, and the ledger under-reported it threefold¶
Reported from a production installation four hours after upgrading, with the numbers.
Cache::initializeAdapter('redis') fell back to memcached when a connection blipped, and
the fallback threw:
What it cost was not the cache. The fatal killed the worker mid-task, so the row stayed
processing behind a lock nobody held and nothing picked it up again; systemd restarted the
worker five seconds later to do it again. Two hours:
Tasks orphaned in processing |
101 |
| Failures actually recorded | 34 |
The gap is the part worth reading twice: the worker died before it could write its own failure, so the ledger under-reported by a factor of three. From outside it looked like a queue that was slightly unwell, and it was a queue losing work.
Fixed — a cache miss is never fatal¶
Adapter construction is wrapped, and anything thrown lands on the local disk adapter with the exception's class and message in the log. If the filesystem fails too, caching is switched off — a state the file branch could always reach and every caller handles.
A caching layer whose fault domain is larger than the thing it accelerates is not a caching layer.
Two wrong answers first, both caught by the suite¶
Worth recording, because the wrong answers are the plausible ones.
An in-memory store. ArrayAdapter looked like the kinder floor: per-process memoisation
instead of a thundering herd. It silently changes the semantics every caller was written
against, because an array store is per instance — so Database::cacheflush() clears one
object's memory while the data sits in another's. A cached value a flush cannot reach is
worse than a miss, and
UsersControllerTest::testTheActivityEndpointAnswersAnEmptyListWithNoTable said so exactly:
it drops a table, flushes, and requires an empty answer, and it got the previous test's row.
Verified like-for-like, same filter and order, with and without the change — 1 failure
against 0.
Switching caching off. Worse. Nine features here are built on the cache, and one of them
is TotpReplayClaim — single-use enforcement for a TOTP code. "No cache" removes a security
control, quietly, which is not a degradation anybody would choose.
file is the answer. Local, so it is nobody else's data; shared across processes and
flushable, so the semantics hold; the ladder's historic last rung, so no feature loses its
cache. And it is what the filing asked for: the complaint was redis silently becoming
memcached — a second network store holding different data — not redis becoming the local
disk.
Changed — redis no longer silently becomes memcached¶
The ladder was redis → memcached → memcache → file, applied on a connection failure. They hold different data. A worker that moves to a different, empty cache mid-run reads a different world from the web requests beside it, and the only thing saying so is a log line nobody is watching at the time.
A cache on the local disk is nobody else's. A cache on another server is. So a failed
backend goes straight to file, and the network rungs between are opt-in:
Two existing tests asserted the old ladder and now assert the new answer, with a third covering the opt-in path — because "the ladder still works when asked for" is the half a behaviour change like this can quietly lose.
The third question — and this answer was wrong¶
The filing ranked the class-resolution mystery last and said it might be a consequence rather than a cause. It is, and the guess offered here was not it.
What was written: that a deploy swapping the release directory under a long-running daemon left the read pointing at a path that was gone. It fitted every observation, which is exactly why it was worth stating as a hypothesis and not a finding.
The real cause was found the next day, and it is in the framework: the redis connection leaked one socket per cache category until the worker hit its file-descriptor ceiling, at which point the autoloader could not open a class file either. See 7 September. The class was never missing; the process had run out of descriptors to open it with.
Documentation¶
- Cache guide — When a cache that was working stops: why a miss is never fatal, why redis does not become memcached, and the opt-in key.
An optional credential would have been stored in plaintext with no key¶
Not reported as a framework bug — filed under "one thing for us, not for them" in the note
closing the db-inspect filing. It is a framework bug, and it is mine from yesterday.
database_readonly_dsn went into ENCRYPTED_SETTINGS, and I took that to mean "it is
encrypted". It does not. setSetting() encrypts only when Encrypter::isAvailable(), so on
an installation with no APP_KEY — which is the reporting installation's production server
today — configuring a read-only account would have written a database password into the
settings table in the clear. Silently, and looking exactly like the encrypted case.
That is the failure I wrote about in the same commit as the setting: somebody believes they have a boundary and does not.
Fixed¶
A second, smaller list — KEY_REQUIRED_SETTINGS — whose members are refused rather than
downgraded:
Refusing to store `database_readonly_dsn`: it is a credential and this installation has no
APP_KEY to encrypt it with, so the value would go into the settings table in plaintext. Run
`php pramnos key:generate` first.
Refused before anything is stored, in memory as well as in the row: a value that reached the
in-memory store before the throw would be readable for the rest of the request by anything
calling getSetting(), which is most of the exposure.
Why not simply refuse every credential¶
Because smtp_pass's reasoning still holds and is written down: an installation that never
ran key:generate must still be able to save its mail settings, and an unreadable credential
is worse than the exposure — the row converts itself once a key exists.
database_readonly_dsn is optional. Nothing stops working without it; db-inspect uses
the ordinary connection. So "store it in plaintext rather than fail" trades a database
password for a convenience nobody asked to pay for. A refusal carrying key:generate is one
command from resolved; a password in a settings table is not something anybody notices.
The distinction has a test of its own, so that "refuse a credential without a key" cannot quietly grow to cover the case where it would break a working installation.
Four of the tests written yesterday stored a DSN and now fail on the refusal, correctly.
They carry an APP_KEY — which is also more faithful: a real installation configuring a
read-only account has one.