Skip to content

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 Database class took the whole MCP server with it
  • Scopes said to subclass it, and then never asked the subclass
  • The migrations ledger was created without the prefix and checked with it
  • db-inspect withheld 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 by Token::load(), McpController::scopesOf() and whoami. 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 space two scopes
    123 123, an int ["123"]
    "mcp" "mcp", a string a 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 with explode(' ', …), so a row written as JSON answered insufficient_scope for an openid grant it actually had.

  • Oauth::introspect() returned the column verbatim. RFC 7662 §2.2 says scope is 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() and User::addScopedToken() now write space-separated, which is what AccessTokenRepository has 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

  • instanceof rather 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:call now reports what could not be registered instead of dying, and mcp:serve writes 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. The instanceof fixes 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

// app.php
'scopes' => ['provider' => \App\Scopes::class],

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:

In Database.php line 1377:
  Duplicate column name 'scope'

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, five SELECTs, INSERT, DELETE — plus the hasColumn() 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 COUNT and 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

'maintenance' => ['exempt' => ['adminer', 'devpanel']],

['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

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:

Error: Class "Pramnos\Cache\Adapter\MemcachedAdapter" not found

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:

'cache' => ['method' => 'redis', 'fallback' => true],

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.