Skip to content

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 is null and null reads as "no". That is what bit the migration widening, the schema test and now the seeder.
  • The seeder skips hasDefault, isPrimary and isForeign. The last is the line worth keeping separate: tokenactions.urlid points 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

  • ColumnDetailsTest on 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 reports nextval(…) and no auto-increment marker, MySQL reports PRI and 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 by getColumns()'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.
  • $truncated is false rather than unset when nothing was cut, so if ($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 @source should keep automatic detection, and source(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 existing new MediaObject() is unaffected — and it makes the wrong spelling impossible rather than merely documented. 0, '' and null are 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, because COLORIZE adds 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.