Skip to content

21 September 2026

13 changes:

  • A generated controller could serve another tenant's row
  • GDPR erasure had no extension point — and could not run at all without authserver
  • The generated list endpoint disagreed with the read of the same row
  • The scaffolded SPA client could not send a file
  • sURL is the script's base, so the framework now has a site root
  • The scaffolded translation endpoint has never once answered
  • Two scaffolded views landed where the resolver never looks
  • The scaffold ships a screen sweep now
  • A migration that adds a column left the model's column list an hour stale
  • There was no supported way to post a form in a test
  • The screen sweep asked for view and edit without a subject
  • …and left the framework's own tables for every application to seed
  • The bind-mount blink came back, in the half of the fix that had no retry

A generated controller could serve another tenant's row

A model that scopes itself to a tenant does it by overriding load(): call parent::_load(), look at the row, and refuse it when it belongs to somebody else. The refusal is written as return a different object — and _load() has already written every column onto $this by the time the override gets to look.

So this, which is what create:crud generated:

$model = new Thing($this);
$model->load((int) $id);           // return value discarded
if ($model->thing_id == 0) {       // populated, so this passes
    return ['status' => 404];
}
$data = $model->getData();         // somebody else's row

One project had it in nine call sites, two of them MCP tools an outside assistant can reach. The model's own isolation test passed throughout, because a test naturally writes $found = (new Thing())->load($id) and uses the return value, which is correct. The defect lived in the gap between the contract the model documents and the shape the generator emits — and the generator's shape is the one every project has, in every controller, by default.

Fixed

  • Every generated load() is an expression: $model = (new Thing($this))->load($id);. Six call sites across api-controller.stub and crud-controller.stub, and two more the guard found in crud-model-test.stub — the generated test asserted on $gone->{{ primaryKey }} after discarding the return value, so on a scoped model it was asserting about the wrong object too.
  • Model::forget(), the other half: it restores every column the load wrote to what a fresh instance has, empties _initialData and marks the model new, so a later save() would insert rather than update the row that was refused. With both, the refusal holds whichever shape the caller was written in.

forget() clears exactly the columns _initialData names — not a list the model declares, because a hand-written list is right until somebody adds a column and then leaks exactly that one, and not every public property, because a model may carry state that is not a column. A property declared with a default goes back to it; a typed one with no default is unset, since assigning null to public int $organization_id is a TypeError.

Added

  • GeneratedCrudSafetyTest::testEveryGeneratedLoadUsesTheReturnValue, which is what found the two extra sites, and ModelForgetTest — seven tests including the three property declarations that each take a different branch.

GDPR erasure had no extension point — and could not run at all without authserver

The export half of Article 17 has always been extensible: buildExportData() fires account.data_export and merges what listeners return. The deletion half was a hard-coded list of six framework tables plus users, so an account deleted under Article 17 left its organisation, its content and its connected accounts behind — and orphaned them, every created_by pointing at a user id that no longer exists.

Added

  • account.data_erase, fired with the user id before the framework's own deletes. An application's rows almost always carry a foreign key to users; fired afterwards, the framework's own DELETE FROM users would already have failed on the children, and what is left is an account half erased — worse than one that was never started.
  • A listener returning false stops the erase. Event::fire() short-circuits at the first one, so the listeners after it do not run either — two listeners where one has already deleted its rows is exactly the half-erase the ordering exists to avoid.

Two things the framework deliberately does not decide, and the guide says so: in a multi-tenant application the data belongs to the organisation rather than to the person, and nothing cascades into a table with no foreign key — which is most hypertables, and usually the largest thing left behind.

Fixed

  • A table this installation does not have is skipped rather than fatal. Five of the six are authserver.*, and authserver is a feature: an installation that never enabled it has never had them, so the delete raised, deleteaccount() caught it, and every user got "An error occurred while deleting your account. Please try again." — an erasure that could not be performed at all, reported as something transient. The skipped tables are logged, because "this installation has no such table" and "the erase missed something" look identical from outside.

Found by the new test, in a full run where the table happened to be absent; running the file alone did not reach it. The test builds the table and drops it now, so the branch is exercised deliberately rather than by luck.

The generated list endpoint disagreed with the read of the same row

create:crud emitted getApiList(..., useGetData: false), overriding its own model's default — getApiList() declares $useGetData = true.

With false the list returns raw database rows while every other action on the resource returns getData(). Any column the model transforms — a timestamptz turned into Unix seconds, a tinyint into a boolean, a cast, a computed field — then has two shapes depending on which endpoint was asked, and the front end formats one of them with new Date(value * 1000) and draws 1 January 1970. It reads as a rendering bug for about a week.

It is found once per resource, by whoever happens to open that screen: one project fixed it in one model in September and hit the same thing in another four days later, with a comment in the first file explaining the second.

Fixed

  • true, with the reasoning at the call site and the opt-out named: if a listing is measured and hot, pass false deliberately. Correctness is the default.
  • A guard asserts both the emitted argument and that the model's own default agrees, so a controller cannot go back to compensating for it.

The scaffolded SPA client could not send a file

lib/api.js set Content-Type: application/json whenever there was a body and JSON.stringifyd it. JSON.stringify(new FormData()) is the two characters {}, so an upload arrived with no file and the server answered "no file arrived" — which is then looked for on the server, where nothing is wrong.

The cost is larger than an upload. Without this, the way to send a file is a bare fetch beside the client, which then misses the apiKey header, the access token, credentials: 'same-origin' and the debug recording — the four things the client exists for.

Fixed

  • A FormData body is passed through unencoded and carries no declared content type: multipart needs a boundary the browser generates, and one set by hand cannot have it, so declaring it makes the body unparseable even once it is sent intact.
  • The debug panel records FormData{caption=…, file=(file "shot.png", 2 bytes)} instead of [object FormData]. The contents are deliberately not read — a second copy of an upload in memory, to display something nobody can read. What is worth seeing is whether the field is there and whether the file has a size, which is the whole of "did it attach".

Three tests in tests/js/spa-api-client.test.js, including the JSON control — without it, a client that never encoded anything would pass the other two.

sURL is the script's base, so the framework now has a site root

getUrl() ends in dirname($_SERVER['SCRIPT_NAME']), which is the site root right up until a project grows a second front controller:

www/index.php      →  sURL = https://example.com/
www/api/index.php  →  sURL = https://example.com/api/     ← same site, same request

So an asset built during an API request as sURL . 'uploads/x.jpg' is https://example.com/api/uploads/x.jpg — a 404. The expensive part is that it is absolute and well-formed, so every "is this a fetchable address" guard passes it: one was handed to an external service, which fetched it, got a 404, and answered with a container error that never mentioned the URL. The visible half of the bug was a broken thumbnail; the half that cost the afternoon was silent.

And getUrl() cannot answer at all from the command line — cron has no SERVER_NAME — so a scheduled task putting a public URL in an email, a webhook payload or a feed builds http:///uploads/x.jpg.

Added

  • Pramnos\Http\SiteUrl. SiteUrl::to('uploads/x.jpg') resolves APP_URL, then 'site_url' in app/config/app.php, then the request — and from the request it derives the document root rather than the script's directory, by removing the part of SCRIPT_FILENAME that sits below DOCUMENT_ROOT from SCRIPT_NAME. So www/api/index.php under www/ answers /, and a site mounted at /shop keeps its /shop.
  • With nothing configured and no request it answers '', and to() returns the path unchanged. Empty rather than a guess: a relative path is visibly incomplete to whatever receives it, where a wrong absolute URL is accepted by everything and fetched by something.
  • A site_url health check, registered by default. It reports degraded while the value is only inferred, because a web request almost always infers a usable one — so "it works on the site" says nothing about whether the scheduler can build a URL. It reports the resolved address either way, since the common mistake is not leaving it unset but setting it to a staging host, or to http behind a TLS-terminating proxy.
  • init writes APP_URL into .env and .env.example, blank. A development URL written there is copied into production by the first person who copies the file, and a wrong absolute URL is the failure the whole key exists to prevent.

Documentation

  • getUrl()'s own docblock now says what it returns and what it is not, with the example above — which was the first of the two things the report asked for.

The scaffolded translation endpoint has never once answered

Three causes in one feature, and each is enough on its own.

The route could not match. LanguageController declared #[Route('/api/1.0/language')] — the only endpoint in a scaffolded project declaring its own route rather than being registered in src/Api/routes.php. The web server strips /api before the API application sees a request (www/api/index.php serves /1.0/…, which is why routes.php groups everything under the version alone), so that path matched nothing from the day it was written, in every environment.

The client could not authenticate. lib/i18n.svelte.js sent a bare fetch with no apiKey header, and ApiAuthMiddleware refuses anything without one before routing. So even with the path fixed the answer was 403.

And the two ends spoke different vocabularies. Language::getLanguages() answers the file names in app/language/ — a project shipping en.php gets ['en'] — while the controller's fallback and default_language were both the word english, which is in no such list. Every request fell through to a name with no file behind it, including a request for a language the project genuinely ships. The two agreed only where the file happened to be called english.php.

Why none of it surfaced. The i18n client treats any non-ok answer as "no translation available" and returns false, so the SPA falls back to English silently. A product that has never rendered a translated string looks exactly like one nobody has translated yet — the failure has a plausible innocent explanation, which is the most expensive kind.

Fixed

  • The route is registered in src/Api/routes.php beside /status and the rest, and the #[Route] attribute is gone. The endpoint that was not declared where the others are is the one that did not work, which is the argument for declaring it there — and two declarations of one route is a second thing to keep in step.
  • i18n sends the application key, read from window.__PRAMNOS__. Not imported from lib/api.js: that file imports t from i18n, and the cycle is why the module imports nothing.
  • The fallback is $available[0] — a language this installation actually ships — and an installation with no language files at all is answered with an empty catalogue rather than a name pointing at nothing. default_language is validated against the list rather than trusted.

Added

  • tests/js/spa-i18n.test.js, which renders the stub and drives the real module against a stubbed fetch: the key is sent, a named language skips the token, a refused request falls back without throwing, and the reported language is the server's rather than a literal. The framework shipped this module without ever running it, which is how all three causes survived.
  • Two guards in GeneratedSpaPathsTest: the language route is registered with the others and carries no attribute, and no generated route path starts with /api — the same mistake in the shape it would take next time.

Two scaffolded views landed where the resolver never looks

init wrote src/Views/account/dashboard.html.php and profile.html.php. Neither has ever been rendered: Account::display() calls getView('dashboard') and profile() calls getView('profile'), which resolve to src/Views/dashboard/ and src/Views/profile/ — so a directory called account is one nothing looks in.

Worse than dead code, because they look like the account screens. Somebody wanting to change the account page finds them, edits them, reloads, and nothing happens — and they were raw Tailwind where every other view is daisyUI, so even that was not a visible hint.

Fixed

  • They are not written at all, and not moved to the right directory either, which is the tempting fix. scaffolding/themes/*/views/dashboard/ and .../profile/ already ship working versions in the project's own theme; a copy under src/Views/ would take precedence over them and replace a maintained screen with a frozen one. project:publish-views is how a project takes ownership of a view, and it copies the version that actually renders.

Added

  • A guard in PublishedViewsAreReachableTest: every src/Views/<dir>/ the scaffold creates or writes into must be a <dir> some controller asks for with getView().

Writing it cost two attempts at one regex — \\\\? is the regex \\?, a literal question mark, in either PHP quoting style — and both times the only thing that said so was the emptiness assertion the sweep carries. Which is the argument for carrying it.

The scaffold ships a screen sweep now

A controller is easy to test; a rendered view is not, and that is where a project's uncovered code lives. A scaffolded project starts around 78% coverage and almost all of the gap is views and the actions that render them — a 600-statement admin view.html.php at 0% is a normal finding.

Every application writes this sweep eventually, or never writes it and ships a view that fatals. The ones already found this way: an entire admin area answering 404 because init wrote no admin block, a /Debugbar that ended a run with a bare exit, and the two account views above. All three render perfectly in whichever screen somebody happened to open.

Added

  • tests/Integration/ScreenSweepTest.php, written by init. It discovers every controller under src/Controllers/ and src/Admin/Controllers/, requests each default action and every read action it declares, and asserts under 500 and not 404 — with DELIBERATE_404 carrying a reason per exception, so "this 404 is fine" is a decision made once rather than a rule that swallows the next real one. Roughly 78% to 93% without anybody writing an assertion.
  • Two places to edit, both marked: seedRows() and actingAsAdmin(). Seed one row in every table a screen lists — without it every list renders its empty state, which is the half of a template nobody gets wrong; the row loop is where a renamed column starts printing nothing.
  • A 403 is a pass. The action ran and refused, which is the code being exercised; demanding 200 everywhere would be asserting that nothing is protected.

The allowlist is the safety, and it has its own guard

READ_ACTIONS is an allowlist, never a denylist. A GET to delete/5 on a controller that does not check the request method deletes row 5, and "most controllers check" is not something to bet a suite on — least of all one that runs on every developer's machine.

ScreenSweepStubIsSafeTest fails if a mutating verb appears there, so adding one is a decision somebody makes on purpose. delete is refused; deleteaccount is allowed and is pinned as deliberate — it is the confirmation screen that asks for a password, and the deletion itself is a POST.

A migration that adds a column left the model's column list an hour stale

getColumns() caches a table's schema for an hour. SchemaBuilder invalidates the tables its own DDL methods touch, so a migration written against the builder is covered — but DDL is explicitly allowed to be raw, because the builder cannot express every engine's grammar, and a raw statement flushed nothing.

ALTER TABLE channels ADD COLUMN is_competitor … therefore left schema_columns_channels holding the old list. Every list built through getApiList() kept answering without that key; the payload indexed it by name, so an undefined-key warning printed ahead of the body, the JSON would not parse, and the screen read "Could not load your numbers" — with no status, because nothing had refused anything.

The read path's own comment called this "visible, harmless, and fixed by waiting". It is harmless to a reader that walks the row. Every generated controller and every hand-written payload indexes it by name, and "harmless" is what stopped anybody thinking about it.

Fixed

  • MigrationRunner::run() calls Database::forgetAllColumns() when a batch ran anything. Every table, not a list of what changed: the runner cannot know which tables a raw statement touched without parsing SQL, and a list that is nearly right is worse than none — the table it misses is the one the migration was about. A migrate that finds nothing pending flushes nothing, which is the common case on every deploy.
  • Both cache keys per table. getColumns() caches under whatever string it was handed, so #PREFIX#channels and pramnos_channels are two entries for one table and the catalogue only knows the second — flushing one of them is a flush that reports success and leaves the stale answer in place.
  • The comment says what it costs now.

Added

  • SchemaBuilder::tableNames(), pinned to the connected database because information_schema spans every database on the server, and aliasing table_name because MySQL answers it in upper case.
  • ColumnCacheAfterMigrateTest, which drives a raw-SQL migration through the runner. It asserts first that the cache is caching — a disabled cache would make every other assertion pass on a runner that does nothing — and that an empty batch does not flush.

Writing it turned up its own version of the bug: the cache outlives a test, so two tests sharing a table name share an entry and the second reads what the first left. Each test has a table of its own now.

There was no supported way to post a form in a test

TestClient::submitForm() threw submitForm is not yet fully implemented while the class documented it as the way to post a form. So a test that wanted to exercise registration, a password reset or a settings form built the POST by hand — and the hard part is the CSRF token, whose field name is a private property and whose value is getFingerprint(), also not public.

The only way in was a regular expression over getTokenField()'s markup, in every application that tests a form. Those are the highest-value tests in an application with accounts in it, and the ones most likely to be skipped, because the first hour of writing one went on this rather than on the behaviour.

Added

  • Session::tokenParameters() — three lines, returning [field name => fingerprint], the same two strings the hidden input carries. tokenParameters(true) for the IP-pinned variant, matching getTokenField(true).
  • TestClient::submitForm(), implemented. It reads the form off the page the client last received: the action, the method, and every field already rendered, the hidden CSRF input included. $data overrides by name, so a settings form with thirty fields and one under test submits the other twenty-nine as the page had them.

The client keeps the last response for it — recorded in call() rather than at each return, because the dispatch has five of them and a client that remembers four pages out of five is worse than one that remembers none.

  • Tests for both. tokenParameters() is asserted against getTokenField() rather than against a literal, because the contract is that the two agree: a test naming the field itself would pass while they drifted, which is the only way this can go wrong.

The screen sweep asked for view and edit without a subject

Two days after it shipped, and found by adopting it: a project's coverage fell from 74.8% to 45.0%.

/admin/Users/view with no id is a valid request and answers 200, so the sweep passed. What rendered was the "nothing selected" branch — four lines at the top of a template whose six hundred are the point. The headline number the sweep was shipped for came entirely from the half of the file it never reached.

It is the failure the stub's own doc-block warns about one paragraph earlier, about rows: seed a row or every list renders its empty state. A detail view has the same empty state and no row to seed, because what is missing is not data but a subject in the URL.

Added

  • subjectFor(string $prefix, string $action): string, beside seedRows(), defaulting to '' — so a project that overrides neither is exactly where it was. The doc-block shows it as a match on the action rather than one shared id, because handing view a channel id where it wants a user id renders the "not found" branch and the sweep reports success over that too.

…and left the framework's own tables for every application to seed

After the sweep landed, the single largest uncovered file in a project on this framework was still Admin/Views/users/view.html.php — 355 of 605 statements, a quarter of everything uncovered anywhere. The file is the framework's, the controller behind it is the framework's, and all seventeen tables it draws from are the framework's.

The stub was emphatic that an empty list renders the empty state and that the row loop is the half worth executing — and then left the application to work out, table by table and required column by required column, how to make the framework's own biggest view run. Every project would spend that same afternoon, or not spend it and carry a six-hundred-line file at 58%.

Added

  • seedFrameworkRows() in the stub, run before seedRows() and not something an application should have to touch. One row in the activity log, GDPR requests, passkeys, two-factor, privacy settings, lockouts, tokens, token actions, the mail log and both push tables. Measured elsewhere at 355/605 → 495/605 and a project total of 93.49% → 95.30%.
  • Each insert absorbed on its own: a table that is not there is a feature this installation does not have, and one missing feature must not stop the rest being seeded.
  • Anything unique gets a random value. passkey_credentials.credential_id is unique across the table, so a literal seeds the first test that runs and silently seeds nothing afterwards — the panel renders once and its empty state the rest of the time, which looks exactly like a test that passes. There is a guard asserting that line is a call and not a constant.
  • Required columns nobody named are filled by type. These rows exist so a row loop executes, not so a screen reads correctly, and a NOT NULL column with no default would otherwise refuse the insert on one engine and not the other.

A full run failed on this, the day after Tree::files() landed:

AdminUrlInViewsTest::testTheTailwindThemeUsesNoBootstrapClasses
the sweep found nothing to check

scaffolding/themes/tailwind/views/*/*.php matched nothing, over a directory whose host mtime has not moved since August. It is the VirtioFS blink again — and the sweeps using glob() were the half deliberately left alone, on the grounds that they already assert their result is non-empty.

They do, and that was not enough. The assertion makes a blink a red test rather than a silent pass, which is the important half; but the red is a failure nobody can act on, and re-running is not a fix, it is a coin toss with better odds.

Added

  • Tree::matching($pattern) — glob() with the same one retry, returning [] rather than false so the caller can still decide whether empty is allowed. It waits only when there is nothing to report: a pattern that matches is answered at once, and there is a test asserting that, because a 50ms tax on every glob in this suite would be measurable.
  • The four sweeps that used glob() directly use it. Tree is back at 100% line coverage.