22 September 2026¶
6 changes:
- The screen sweep's seeder invented values for generated and foreign keys
- A capped fetch threw away everything it had already read
- The scaffolded SPA stylesheet let Tailwind scan the whole repository
new MediaObject($id)looked like it loaded and silently did not- The media system has an answer for transparency now
- …and a row for the new methods, which is the half of BC that was missing
The screen sweep's seeder invented values for generated and foreign keys¶
Yesterday's seedFrameworkRows(), reported back the same day by the project it was written
for. Four of the seven tables refused:
passkey_credentials: duplicate key value violates "passkey_credentials_pkey"
mails: duplicate key value violates "mails_pkey"
pushsubscriptions: duplicate key value violates "pushsubscriptions_pkey"
tokenactions: violates foreign key constraint "fk_tokenactions_urlid"
Each was absorbed — correctly, that is the design — so the sweep stayed green and said nothing, and the person card rendered four fewer panels than it could. Measured elsewhere: 399 of 605 statements against 495 with the refusals gone.
The cause is one this repository has now met three times. The "fill required columns by
type" loop checked Default and Key, which are the MySQL spellings:
| MySQL | PostgreSQL | |
|---|---|---|
| default | COLUMN_DEFAULT |
column_default |
| primary key | Key = PRI |
PrimaryKey = true |
| auto-increment | (absent; Key says PRI) |
column_default = nextval(…) |
On PostgreSQL both keys were absent, null read as "no default, not a key", and an integer
placeholder went into a bigserial primary key — colliding with everything a week of
testing had left in the table.
Fixed¶
SchemaBuilder::columnDetails(): one column shape on both engines —nullable,hasDefault,isPrimary,isForeign,foreignTable. Code written against one driver's spelling works on one engine and silently does nothing on the other, because a missing array key isnullandnullreads as "no". That is what bit the migration widening, the schema test and now the seeder.- The seeder skips
hasDefault,isPrimaryandisForeign. The last is the line worth keeping separate:tokenactions.urlidpoints into the route table, and a value invented for it cannot point anywhere. A row so the loop runs is the point; a row claiming something untrue about another table is not.
Added¶
ColumnDetailsTeston both lanes. The generated-key assertion is deliberately the disjunction a caller uses —hasDefault || isPrimary— because the two engines describe the same column differently and neither is wrong: PostgreSQL reportsnextval(…)and no auto-increment marker, MySQL reportsPRIand a null default. Asserting one flag would pass on the engine it was written against.- Two edge cases with a double, for the paths a real database will not produce: a driver
answering
false, and a connection that has gone away. Both are declared bygetColumns()'s contract and neither is reachable from an integration test. - A guard on the stub, forbidding the raw driver keys.
A capped fetch threw away everything it had already read¶
OutboundUrl::readCapped() enforced the ceiling as a failure: past $maxBytes it
returned false and discarded the body, including the part the caller capped the fetch in
order to reach.
A caller reading an OpenGraph card asked for 512 KB, which is generous for a <head>. The
page was 783 KB — an ordinary WordPress front page — so the fetch refused and the screen
said "That page could not be read. The response is larger than 512000 bytes." The
og:title and og:image were at byte 4,000.
A cap exists so a hostile or careless server cannot fill memory. It does not exist to make a large page unreadable — and the things anybody caps a fetch for, meta tags and feed headers and manifests, are at the top of the file by construction.
Added¶
fetch(..., allowTruncated: true, truncated: $truncated). Off by default, because half a JSON document is the shape the caller expects and the failure surfaces somewhere else; on, it returns what it read and says it was cut short, and the caller decides.- Exactly the ceiling, not the ceiling plus the rest of the 8 KB block it was noticed in. A caller that sized the cap against a limit it has to respect gets the number it asked for, with a test for each of three sizes.
$truncatedisfalserather than unset when nothing was cut, soif ($truncated)around a re-fetch does not re-fetch every small page ever read.
The scaffolded SPA stylesheet let Tailwind scan the whole repository¶
The stub wrote @import "tailwindcss"; and then @source "./**/*.{svelte,js}";. In
Tailwind v4 @source adds to automatic detection rather than replacing it, and
detection starts at the git root — so it walked src/, app/, www/ and every previous
build output still on disk.
Two costs. The bundle carried rules for classes that appear only in server-rendered
src/Views/*.php: 63 KB, about 30%, that the SPA can never use — in a project whose two
halves deliberately have separate stylesheets, which is what theme:build is for.
And two builds of identical sources produced different filenames. The bundle is content-hashed and committed, so that reads as a non-deterministic build and was investigated as one twice. The scan reaches over a VirtioFS bind mount, where the container sees part of the tree a beat late and which part varies per run.
Fixed¶
@import "tailwindcss" source(none);. Naming the sources and then scanning everything anyway is not a combination anybody asks for; a project that wants the repository scanned has not written an@source.- A guard asserting the pairing rather than either line, because each alone is right: a
stylesheet with no
@sourceshould keep automatic detection, andsource(none)without one scans nothing and builds an empty stylesheet — a worse failure and a quieter one.
new MediaObject($id) looked like it loaded and silently did not¶
MediaObject declared no constructor, so it inherited Base::__construct(), which takes no
parameters — and PHP does not complain about an argument passed to a constructor that
declares none. The id went nowhere and the object stayed empty.
Every other model in the framework loads from its identifier. This was the one that looked
the same and behaved differently, and the failure is silent and shaped exactly like missing
data: one application's GET /api/1.0/media/{id} answered 404 for every picture that had
ever existed. The screen showed a caption with no thumbnail, which looks like a file
somebody deleted, so it was investigated three times — a broken upload, a broken
.htaccess, a lost file — while the row was in the table throughout. Nothing in a log, and
no status code that was wrong: every caller's empty($media->mediaid) turned an empty
object into "not found".
Fixed¶
public function __construct($mediaid = null), loading when given one. Backwards compatible — every existingnew MediaObject()is unaffected — and it makes the wrong spelling impossible rather than merely documented.0,''andnullare all "no id" rather than row zero, because callers arrive with an unvalidated route segment.
The media system has an answer for transparency now¶
Three bugs in one afternoon in one application, all the same shape — a transparent PNG treated as opaque:
- a logo drawn at 60% came out as a grey rectangle with the logo faintly on it;
- a glow behind a logo was a coloured card, because the obvious way to draw a halo is a filled rectangle;
- and scratch canvases start opaque black, so anything not overwriting every pixel gains a background.
GD's compositing calls divide into ones that respect the source's alpha (imagecopy,
imagecopyresampled) and ones that do not (imagecopymerge, imagecopymergegray) — and
the second group is the one whose signature takes a percentage. So "draw this at 60%"
leads straight to the wrong call. Every application that wants it reaches for that, and
every one gets this bug. It is not knowledge that should be acquired from a screenshot.
Added¶
ResizeTools::blend($destination, $source, $x, $y, $w, $h, $opacity)— fades without flattening.IMG_FILTER_COLORIZE's fourth argument adds transparency, so a clear pixel stays clear and an opaque one becomes as translucent as asked. The source is left untouched, so drawing the same logo twice does not give the second one at the product of both opacities.ResizeTools::silhouette($source, $colour, $alpha)— the image's own shape in one colour. It walks the pixels rather than using a filter, becauseCOLORIZEadds to the colour instead of replacing it and a red logo colorised white comes out pink. The requested alpha is a floor on each pixel's own, so an anti-aliased edge stays soft instead of the glow gaining a hard one.- Both at 100% line coverage, and every assertion is about a corner of the drawn box — inside the box, outside the artwork. That is where all three bugs showed, and it is the pixel a rectangle fills and a silhouette does not. A test that checked the middle would pass on every one of them.
Three dead branches went while measuring that: two imagecreatetruecolor() === false checks
and a zero-dimension guard. Since PHP 8 that function returns an image or throws, and
imagesx() is never below 1 — so none of the three could be reached, and a branch no test
can take is a branch nobody has checked.
A row for the new methods, which is the half of BC that was missing¶
Every change in this run is additive — no existing signature moved — and that is not the whole of the question. A subclass that already declares a method the parent has just gained is a fatal at class load, and PHP says so before a route is matched:
Declaration of App\Models\Thing::forget() must be compatible with
Pramnos\Application\Model::forget(): static
Which is the framework's known worst case: not a page, the whole site and the whole suite, before anything runs. The Upgrade Guide has had a row for a parameter added to an overridable method since the day one of those broke a municipal presentation; it had none for a method added to a class people extend, which is the same failure by a different route.
Measured rather than assumed, in this repository alone:
| Method | On | Subclasses here |
|---|---|---|
Model::forget() |
the base of every application model | 28 |
Database::forgetAllColumns() |
Database |
10 |
SchemaBuilder::columnDetails(), tableNames() |
SchemaBuilder |
4 test doubles |
Session::tokenParameters() |
Http\Session |
0 |
ResizeTools::blend(), silhouette() |
ResizeTools — static, and a static collides too |
0 |
forget() is the one worth grepping for: it is an ordinary word for a helper, it is
protected, and protected is exactly where a model would put one.
MediaObject::__construct() is exempt, and that is worth stating because it looks like
the riskiest of the set: PHP does not compare constructor signatures, so a subclass with its
own constructor of any shape keeps loading. Checked rather than remembered.
Documentation¶
- One row naming every method, so somebody upgrading can grep for the names rather than reading a changelog. Rule 6's third column has to be grep-able; a row that said "some new methods were added" would be a row nobody can act on.