17 September 2026¶
26 changes:
- The activity log filled with an entry nobody can act on
- …and two tokens minted in the same second were the same token
- One unlayered rule drew a border on every daisyUI component
- Every generated SPA form was written in daisyUI 4
- The scaffolded
CLAUDE.mdtaught a controller that fatals at autoload timescale:ensurereported conversions and policies that did not happen- Three framework migrations failed for ever on a TimescaleDB most hosts can install
- The scaffolded git webhook fatalled on the first request it ever received
- …and the scaffolded
.gitignorecommitted half of a key pair, next to a hard-coded Redis host health:checknow says which TimescaleDB this is, and what it actually built- The suite moved to the oldest supported TimescaleDB, and found four things that day
- The continuous-aggregate fallback is removed: it could not tell two causes apart
- A migration that did nothing for a missing capability was recorded as applied
- The TimescaleDB capability was answered from config, not from the database
- …so
migratecreates the extension itself now, or stops and says how - Forty tests that could not fail, and a guard that could not either
- Three tests that had never run anywhere, and the supported-version claim
- Forty-five sweeps that would have passed over nothing
create:commandwrote to a directory the scaffold does not use- The ORM guide documented a query API the class does not have
create:crudgenerated a cross-tenant write, and a model that cannot be scoped- …and a search source against a column the table does not have
- Scaffolded code named three classes that do not exist, and the webhook fix was half applied
- The administrator a scaffold creates is Root now, not one tier below it
- A tracking write could sign somebody out, and a 401-character URL made it
- Eight controllers ended a request eight ways, and one of them ended the test run
The activity log filled with an entry nobody can act on¶
SessionExchange::issue() recorded a session_exchange entry every time it ran. Every
other action in that log is a decision somebody made — signed in, changed a password,
added a passkey, revoked an application — and each occurrence is worth a row. An exchange
is not a decision: a SPA asks for one whenever it opens without a token, so a new tab, a
hard refresh, a private window and an expiry each added one.
The consequence is not volume, it is legibility. An activity log is read by a person looking for the thing that happened to their account, and one that has to be filtered before it can be read is one nobody reads.
Fixed¶
- The entry is written once per session. What is audit-worthy is that this session obtained an API credential, and that is one fact however often the page asks again. A new session means a new sign-in — logged as such — and this alongside it.
- The flag lives in the session rather than in a cache or a
SELECT: no query, no eviction policy, and the unit is exactly the thing being exchanged.
Added¶
SessionExchangeMintTestgains both halves: three requests in one session produce three revocable tokens and one log entry, and a new session is recorded again. Written as three requests rather than three calls, because an exchange re-seals the identity assession-exchangeand a second call inside one request is refused by the guard above it — a test written the other way passes while measuring nothing.
Two tokens minted in the same second were the same token¶
Found while writing the test above, and the sharper of the two.
The claims on every token this framework issues are iss, aud, iat, nbf and exp.
Not one of them names the user. So any two tokens minted in the same second were
byte-identical — for two different people as readily as for one — and
usertokens.token_lookup is unique, so the second insert failed.
What that looked like from outside:
- two people signing in at the same moment: the second one's sign-in did nothing;
- a SPA opening two tabs, or retrying:
SessionExchange::issue()answerednull, which reads as "you are not signed in" and bounces to the login page.
Both were reported as something else — a flaky login, a redirect loop — because nothing in either symptom points at a collision.
Fixed¶
- Every token carries a
jti: 16 random bytes, hex-encoded. It is what the claim is for (RFC 7519 §4.1.7) and it is the only thing making two tokens differ. Both minting sites —IssuesAccessTokens::issueToken()(the API login, the second factor, passkey sign-in) andSessionExchange::mint()— take it, because they are the two places tokens come from. - Nothing verifies it. A token is still resolved to its user by its
usertokensrow, exactly as before, and every existing token keeps working.
Added¶
SessionExchangeMintTest::testTwoExchangesInTheSameSecondAreDifferentTokens()andApiAccountControllerTest::testTwoTokensIssuedInTheSameSecondDiffer()— one per minting site, each asserting the pair differs rather than asserting a format, because the format is not the contract and the collision is.
Documentation¶
docs/Pramnos_Authentication_Guide.md— both, under the session-exchange section.- Two rows in
docs/Pramnos_Upgrade_Guide.md.
One unlayered rule drew a border on every daisyUI component¶
scaffolding/themes/tailwind/style.css restores a sensible default border colour with
html *, html ::before, …. Its comment reasons carefully about specificity — "raises
specificity to (0,0,1) so this beats Tailwind's Preflight" — and that is the right
analysis for a world without cascade layers.
daisyUI 5 ships entirely inside @layer base / daisyui / utilities, and an unlayered
rule beats every layered rule regardless of specificity. So html * (0,0,1) won against
.btn-ghost (0,1,0): every component that had explicitly asked for no border got one. A
btn-ghost sets --btn-border: #0000 and still showed an outline; so did inputs and cards.
How it was found is the part worth keeping. An application's SPA does not load this file, so the same markup with the same classes looked different on the two halves of one product, and comparing class lists explained nothing. Two headers, one class list, two appearances.
Fixed¶
- The rule is wrapped in
@layer base. Everything it intends still applies — Preflight is in the same layer, where specificity does decide — andbaseis declared beforedaisyui, so a component's own border wins as it always should have.
Added¶
ScaffoldThemeStylesheetTokensTest::testBlanketSelectorsInTheTailwindThemeAreLayered()— any selector addressing every element must sit inside a layer. Asserted rather than left to review, because reasoning about specificity is exactly what does not find this. Scoped selectors (.pf-home h1,.breadcrumb) are exempt: they style this theme's own markup, which daisyUI has no opinion about.
Every generated SPA form was written in daisyUI 4¶
The scaffolder pins daisyUI 5 (^5.0.0, vendored at 5.7.22) and generated daisyUI 4
markup. form-control, label-text and label-text-alt were removed in 5 and style
nothing, so the wrapper that stacked a label above its field stopped doing anything: the
label became an inline-flex box beside a field shrunk to its content, and three fields
rendered as three ragged rows of different widths — in the screen create:crud writes for
you, on first sight.
Fixed¶
Field.svelte,ConfirmDialog.svelteand the Svelte shell's sign-in screen use the v5 shape. Three details are deliberate:fieldset, not adiv. It stacks its children with no class at all, so the layout is right even where daisyUI is not loaded — which adivleaning onform-controlwas not.- The name stays on a real
<label for>, not a<legend>. A legend names a group; a control whose only accessible name comes from one has no name of its own. - Inputs carry
w-full, because in 5 nothing stretches them any more.
Added¶
GeneratedSpaPathsTest::testNoGeneratedSpaMarkupUsesRemovedDaisyUiClasses()— swept over the SPA templates only.form-controlis also Bootstrap's class for an input and the bootstrap theme's views use it correctly; the two share a name and nothing else.
Documentation¶
docs/Pramnos_Theme_Guide.md— Layers, not load order, decide the last two, in the daisyUI section, with what belongs in a layer and what does not.docs/Pramnos_Application_Styles_Guide.md— the v5 form shape and the reasoning behind each part of it.
create:command wrote to a directory the scaffold does not use¶
Three things, each of which left a project slightly wrong in a way nothing failed on.
Where. It wrote src/Console/Commands/; init writes src/ConsoleCommands/, and
src/Console.php — the file that has to add() the command — invites the next one
there, by name, in a comment. Both namespaces autoload, so nothing broke: a project
simply ended up with commands in two places, by a convention that depended on which tool
wrote the file.
What it is called. CollectChannels became app:collectchannels, a lower-cased
concatenation, against the namespace:verb convention the console guide states at the top
of its own command table.
Being reachable. Nothing registered it, so the command did not appear in <cli> list
until somebody added the line by hand — and the output did not say so either. The one
thing a generated command must be is runnable.
Fixed¶
- The directory is discovered:
src/ConsoleCommands/when the project has it,src/Console/Commands/when that is what it has instead, and the former for a project with neither. A new hard-coded path would only have split the convention the other way. - The name is derived as
noun:verb— the first word of a PascalCase class name is nearly always the verb, soCollectChannelsischannels:collectandSyncStockisstock:sync. One word has no noun to group under and keepsapp:.--command-nameoverrides it: a guess is still a guess, and what it replaces was not a guess but a concatenation. - The registration is appended to
src/Console.php, on the invitationinitwrites — the same thingcreate:cruddoes forscreens/registry.js. Idempotent, and reported rather than enforced: a project may have restructured its console. - The generated test asserts something.
test.stubisassertTrue(true)— a passing test with no subject, which in a project with a coverage floor is a hole the report cannot see. A command's name is its entire public surface, and its first failure mode is a fatal before it prints anything; the newcommand-test.stubpins both with aCommandTester.
The ORM guide documented a query API the class does not have¶
User::find(42), User::where('email', …)->first(), User::all(),
User::create([...]), User::withTrashed(), Post::published(). None of them exists:
OrmModel declares no __callStatic and none of its traits add one. A method written
from the guide died with Call to undefined method — at runtime, in whatever path first
reached it.
docs/ is not export-ignored. It ships inside the composer package and sits in every
project's vendor/, which is the point: it is what somebody — or an assistant — reads to
learn the API. Wrong documentation is worse than missing documentation, because it is
followed.
Fixed¶
- The CRUD, relationship, eager-loading, soft-delete and complete-example sections are
written against the surface the class has:
new,load(),save(),delete(),_getList(),_getApiList(), andapplyScope()/withTrashed()as the instance methods they are. The Scopes section was already right and is untouched. - A box at the top says the static API does not exist, because somebody arriving with an Eloquent-shaped example in their head needs to be told once rather than left to infer it.
- The soft-delete section documents the switch that turns it on (
protected bool $softDelete = true), which it never mentioned.
Added¶
tests/Unit/Docs/OrmGuideMatchesTheClassTest.php— everyModel::method(in the guide's code fences is a static the class really declares. Prose is exempt on purpose: the new warning namesUser::find(42)while explaining that it does not exist, and a sweep that could not tell the two apart would forbid the warning along with the mistake.
create:crud generated a cross-tenant write, and a model that cannot be scoped¶
Three defects in what it writes, and the scaffold is multi-tenant by default because
init enables authserver.
The model extended the legacy \Pramnos\Application\Model. A global scope
registered on that base is accepted and silently never applied, so tenant isolation added
later looks like working code and leaks on every list.
The API controller assigned the tenant from the request.
$model->organization_id = Request::staticGet('organization_id', …) is a cross-tenant
write with a straight face: whatever the caller sends is what the row is filed under. It
also opened with $user = $_SESSION['user']; — unused, and absent on every bearer-token
request, so a project with warnings turned up got one on every create.
Nullable columns passed null into strip_tags(), deprecated since PHP 8.1 and an
error under a strict handler — on the first create that leaves an optional field empty.
Fixed¶
- The generated model is an
OrmModel. Safe becauseOrmModel extends Model: nothing a generated model could already do stops working. The generator also settles the timestamp question at generation time — it knows the column list, andOrmModelstampscreated_at/updated_atby assigning the property, which on a table without them is a dynamic property (deprecated since 8.2) and then an INSERT naming a column that does not exist. ApiCrudController::currentOrganizationId()— new, and the generated line calls it. The answer comes from membership (authserver.user_organizations, active and unexpired): a caller who belongs to exactly one organisation has an unambiguous answer. A caller who belongs to several does not, and it returns null rather than picking the first — "the lowest id that happened to come back" is not a rule anybody chose, and a row filed under the wrong tenant is invisible until somebody else reads it. An application that decides it some other way overrides one method.- Nothing possibly-null reaches
trim()orstrip_tags(), and a nullable column keeps its null rather than becoming""— which is not the same value to a database. - The
$_SESSION['user']line is gone.
…and a search source against a column the table does not have¶
Answering yes to "Make channel findable from the admin search box?" appended
'display' => ['name'], // TODO for a table whose columns are platform, handle,
label.
The cause is one line: introspectTableAsWizardColumns() answers
[$columns, $foreignKeys], and the result was assigned to a single variable. The loop
then walked two lists, neither of which has a type, so nothing was ever textual and
every generated source fell back to ['name'].
The damage is a level further on. The omnibox is registered against a column that does not exist, so the search box finds nothing and says nothing — a search returning no rows looks exactly like a search with no matches.
Fixed¶
- The pair is destructured, so the guess is the table's own text columns.
- A
filterentry is emitted when the table has a tenant column. A search box is the one screen that reaches every table at once, so a source registered without a scope spans tenants by default — and nobody notices, because the results look like results. The generated closure returns1 = 0for a caller with no organisation: no rows, rather than all of them.
Added¶
GeneratedScreenColumnsTest::testTheSearchGuessNamesColumnsTheTableHas()— the assertion the existing test was missing. It checked "at most two" and "not empty strings", both of which an empty array passes, and an empty array is exactly what the generator had.tests/Unit/Console/GeneratedCrudSafetyTest.php— what the generator is allowed to emit: no$_SESSION, no request-assigned tenant, nothing possibly-null intotrim(), anOrmModelbase, and the timestamp settings for a table with neither column, one, or both.
Documentation¶
docs/Pramnos_ORM_Guide.md— rewritten against the real API, as above.docs/Pramnos_Console_Guide.md— whatcreate:commandnow writes, where, and under what name.- Four rows in
docs/Pramnos_Upgrade_Guide.md.
The scaffolded CLAUDE.md taught a controller that fatals at autoload¶
The worked example under Key conventions → Controllers was:
Pramnos\Application\Controller declares public $actions = array(); — untyped — and
PHP does not allow a subclass to add a type to an untyped inherited property. A controller
copied from the guidance file therefore dies with
Fatal error: Type of App\Controllers\Privacy::$actions must be omitted
to match the parent definition in class Pramnos\Application\Controller
not when the action runs but when class_exists() is called on the class — so the page is
a white screen with a stack trace rather than a 404 or a 500 the error handler formats.
This file exists to be copied from. It is the first thing an assistant reads and the last thing anybody doubts, and every scaffolded project shipped the same line.
Fixed¶
- The example registers its actions in the constructor with
addaction()— which is what every framework controller does, what every controllerinitwrites does, and what the dispatcher and the generated "every registered action has a method" test both read. The property form was a second convention for the same thing, and the broken one. - The stub carries the failure beside the example, because somebody who has already hit it is searching for the message, not reading the section.
docs/Pramnos_Framework_Guide.mdgains the same warning under Controller Conventions. Its own example was already correct — the two had drifted, and the wrong one was the one that ships inside every project.
The fix that was not made, and why¶
Typing the parent (public array $actions = []) is the tidier change and the report says
so. It is also a hard break with no benefit today: PHP would then refuse every subclass
that redeclares the property untyped, which is the form that has always worked and the one
every application with a workaround is using. Nothing in this repository redeclares it, but
a consuming application's controllers are not in this repository. Doing both at once is the
one combination that is always wrong, so the example moves and the property stays.
Added¶
tests/Unit/Console/ScaffoldedGuidanceCompilesTest.php— no example in the stub types a property the framework leaves untyped. The parent's declaration is read by reflection rather than assumed, so the assertion flips on its own if$actionsis ever typed: on that day an untyped example becomes the fatal instead, and the test should say so rather than pass.
timescale:ensure reported conversions and policies that did not happen¶
The command printed
✓ channel_metrics: converted to hypertable
✓ channel_metrics: compression enabled
✓ channel_metrics: compression policy added (30 days)
3 table(s) brought in line.
for a table timescaledb_information.hypertables does not list. Run it again: the same
three ticks, the same nothing, exit code 0. Three separate calls had failed, and every one
of the errors was swallowed — runTimescaleStatement() logs the driver message and
returns false, and HypertableRegistry::apply() appended its line without looking.
Nothing breaks, which is what makes it the worst available failure mode. Rows are written and read exactly as before, every test passes and every screen works. What is missing is chunking, compression and a table that stays usable holding years rather than days — found when the table is far too large to convert quickly.
Fixed¶
- A tick is a claim about the database, so it is read back from the catalogue before it
is printed. Every step of
apply()re-checks, and one the catalogue does not show raises — whichtimescale:ensurealready turns into an error line and a non-zero exit. - An interval declared for an integer time column is refused by name. TimescaleDB
requires every offset to have the time column's type; given a
bigintcolumn and'7 days'it answers in terms of function overloads, which reads like a version problem and is not one. The message now names the table, the column, its type and the key at fault. - A number stays a number.
'chunk_interval' => 604800becameINTERVAL '604800'through a(string)cast here and anis_string()there, neither of which knew about the column.add_compression_policyhad the same shape, permanently: it wrapped its offset inINTERVALunconditionally, so an integer time column could never have a compression policy at all.
Told apart by the value rather than by a widened signature — addCompressionPolicy(int|string)
was tried and is a fatal at class load for any subclass that overrides it, which this
repository's own test double demonstrated within a minute.
Added¶
- Four tests in
HypertableRegistryTest: a refused conversion and a refused policy each raise rather than report done, an interval for abigintcolumn is refused by name, and a number for one is accepted and reaches the database as a number. The fake schema builder now changes what the catalogue says only when the call worked — a double that always answered "yes, it is a hypertable now" could not tell the two apart either, which is the bug in miniature.
Three framework migrations failed for ever on a TimescaleDB most hosts can install¶
migrate ended in ✗ 3 failed on every run — ERROR: invalid continuous aggregate view —
which is a thing that gets learned and ignored.
A continuous aggregate is maintained incrementally, so every expression in it needs a
partial/combine form. percentile_cont(…) WITHIN GROUP (…) and COUNT(DISTINCT …) have
none. A newer TimescaleDB accepts them; an older one refuses. Observed accepted at 2.30.0
and refused at 2.26.4.
That is not an exotic host: Timescale stopped building for Debian 11, so on bullseye + PostgreSQL 17 the newest installable package is 2.26.4, and upgrading the extension means upgrading the operating system.
Fixed¶
createContinuousAggregate()falls back to a plain materialised view with the same columns when the aggregate is refused — the branch plain PostgreSQL has always taken. Nothing a consumer reads changes; what changes is how it refreshes.addContinuousAggregatePolicy()decides its branch by asking what the view is (isContinuousAggregate(), new, reading the catalogue) rather than what the server has. Without that, a plain view on a TimescaleDB host would be asked for a native refresh job — failing, logged, and then invisible, which is the shape of failure this whole area has been fixing.
The alternative fix — rewriting the aggregates to drop percentile_cont and
COUNT(DISTINCT) — would remove columns from shipped views that applications read. The
fallback keeps them.
What the fallback is, and what it is not¶
No migration's SQL changed, and no column was removed. On any version that accepts these aggregates — which, measured, includes the one this suite now runs on — they are still continuous aggregates and nothing at all is different.
Worth stating plainly, because the report's diagnosis does not hold here: on TimescaleDB
2.26.4 none of the blamed expressions is refused. COUNT(DISTINCT …),
percentile_cont(…) WITHIN GROUP (…), HAVING COUNT(DISTINCT …) and a join to a plain
table were each measured on it and each accepted, and the framework's own migrations run
there — FrameworkMigrationsTimescaleDBTest asserts authserver.daily_activity_summary
is a continuous aggregate and passes on 2.26.4, with COUNT(DISTINCT ip_address) in it.
So the refusal comes from something else on that host, and the fallback is written for a failure that is reported rather than reproduced. That makes two things matter more than the fallback itself:
- It is narrow. Only
invalid continuous aggregate viewis caught. Everything else — a lock timeout, a permission, a missing column, a SELECT with no time bucket — is re-thrown, and the migration fails the way a migration should. A blanket fallback would have turned any of those into a quiet plain view that nothing refreshes incrementally, which is the silent downgrade this whole day has been about, installed at the one place best able to hide it. - It is tested rather than assumed.
ContinuousAggregateFallbackTestdrives the branch by injecting the reported error, checks that the plain view keeps every column, and — the assertion that matters — checks that an unrelated failure still reaches the caller.
Where the fallback does fire, the cost is real and worth knowing: a plain materialised
view is refreshed in full rather than incrementally, and it cannot answer from data newer
than its last refresh the way a real-time aggregate can. health:check names the views in
that state.
And the aggregates are proven, not assumed¶
All five of the framework's continuous aggregates — authserver.daily_activity_summary,
authserver.daily_2fa_stats, applications.tokenactions_hourly,
applications.application_stats_hourly and _daily — are asserted by
FrameworkMigrationsTimescaleDBTest to be real continuous aggregates, read back from
timescaledb_information.continuous_aggregates. Those assertions now run against the
pinned oldest version rather than against latest, which is the whole point of pinning it.
EveryAggregateIsProvenContinuousTest closes the remaining gap: the sixth. An aggregate
added later, whose migration nobody thought to write an assertion for, would be covered by
nothing at all and could fall back for years without a failing test. The new test walks
ContinuousAggregateRegistry and requires each view to be named in the integration file.
It checks that a test exists, not that it is a good one — the honest limit of a meta-test, and still the difference between an aggregate somebody looked at and one nobody did. Verified by declaring a sixth and watching it fail.
The scaffolded git webhook fatalled on the first request it ever received¶
\Dotenv\Dotenv is vlucas/phpdotenv, which neither a scaffolded project nor the
framework requires. What the framework ships is symfony/dotenv, wrapped in the
loadDotenv() helper that app/config/settings.php has used all along.
This file has exactly one caller and that caller is a machine in production. It cannot fail in development, nobody opens it in a browser, and the only evidence is a red tick in a delivery list on github.com. A project can be deployed, apparently working, with its deployment mechanism dead.
Fixed¶
loadDotenv(ROOT);— the helper is autoloaded by the samevendor/autoload.phpthe file requires two lines earlier.- The deploy commands name an explicit PHP binary. On a multi-version host the unversioned
phpis the system default, which can be years older than the one serving the site — and composer inherits it through its own#!/usr/bin/env php, socomposer installrefuses to run before anything else does.
…and the scaffolded .gitignore committed half of a key pair, next to a hard-coded Redis host¶
The keys. /app/keys/*.key was ignored and public.key then un-ignored, on the
reasoning that a public key is public. It is — and that is not the question. The question
is whether it is environment-specific, and half of a pair always is. The first
deployment shipped development's public key to production, where no pair was then generated
because one of the two files was already there: the application served pages normally and
only the OAuth2 server was broken, found by whoever first tried to sign in through it. The
framework's own health check had been saying signing_keys: DOWN the whole time.
Both un-ignores are gone. vapid_public.key has a genuine argument for being fixed across
environments — browsers pin it in every push subscription — but that is an argument for
shipping both halves deliberately, through whatever carries the rest of an environment's
secrets, not for committing one of them.
The cache. Every other value in the generated settings.php goes through envvar()
with a documented default; the cache block was literal:
cache is the Docker Compose service name. On any host that is not that Compose file the
connection fails — and the cache layer falls back to the file driver without raising
anything, so the application is simply slower, for ever. The only place it is stated is
one line of health:check: Running on file, configured for redis.
Now envvar('APP_REDIS_HOST', 'cache') and envvar('APP_REDIS_PORT', 6379), with the keys
in .env and .env.example beside the database ones. The Compose name stays as the
default, so development works with no .env change.
Documentation¶
docs/Pramnos_Hypertable_Guide.md— An integer time column is a different declaration, and When TimescaleDB is present but too old for the aggregate.- Rows in
docs/Pramnos_Upgrade_Guide.mdfor the ticks that now raise and for the keys.
health:check now says which TimescaleDB this is, and what it actually built¶
Two of today's entries end the same way: the database was not the shape it had been asked for, and nothing said so. The ticks are fixed and the fallback is safe — but both of those are things that happen at migration time, and the question an operator has months later is what is this database actually running.
Added¶
DatabaseCapabilities::timescaleVersion()andtimescaleAtLeast(). Separate fromgetVersion(), which is PostgreSQL's: the two move independently, and it is the extension's version that decides what a continuous aggregate may contain. Memoised — a catalogue read cannot change under a running process.Pramnos\Health\Checks\HypertableCheck, registered by default. It reads back every declaration:
hypertables OK TimescaleDB 2.30.0 — every declaration matches the catalogue
hypertables DEGRADED 2 aggregate(s) are plain materialised views on TimescaleDB 2.26.4
hypertables DEGRADED 1 declared hypertable(s) are ordinary tables: channel_metrics
Degraded, not down, in both cases — the same reasoning CacheBackendCheck documents.
The application is up; its storage is not the shape it asked for. down would page
somebody for a site that is serving every request, and a check that cries wolf gets muted.
A declaration whose table has not been created yet is ignored rather than reported: a
migration that has not run is not this check's subject, and a fresh install should not be
yellow before it has finished setting itself up. And with no TimescaleDB at all it is
plain ok — every declaration has a documented software equivalent there, and reporting
it would leave two of the three supported databases permanently yellow.
- The scaffolded
docker-compose.ymlpins TimescaleDB to2.26.4-pg17instead oflatest-pg17.latestis a moving target that is always at least as new as any real host, so development could not be behind production and therefore could not reproduce it — which is exactly how three migrations failed permanently on a host while every suite run passed. 2.26.4 is a platform fact, not an old choice: Timescale stopped building for Debian 11, so on bullseye with PostgreSQL 17 it is the newest installable package. A project that knows its hosts are newer raises the line; one that does not now develops against the oldest version the framework supports.
The test asserts the tag is pinned, not that it is that number — the number is a decision, and a test that pins the decision as well as the property makes both expensive to revisit.
The framework's own image¶
2.26.4-pg14 does not exist — TimescaleDB 2.26 is not built for PostgreSQL 14 at all, and
the registry answers manifest unknown. So the suite's TimescaleDB service moved to
2.26.4-pg17, which also moves the PostgreSQL lane from 14 to 17. That service keeps no
volume, so nothing persistent was lost; the anonymous volume the image declares had to be
renewed explicitly, which is worth knowing because --force-recreate alone reuses it and
the container then refuses to start on a PostgreSQL 14 data directory.
2.26.4-pg14 could not
be verified from this environment — the registry is unreachable here, and the only tag that
could be confirmed is the -pg17 one already in the local image store. Pinning to a tag
that turns out not to exist breaks the environment for everyone, so it is one line waiting
for somebody who can reach the registry:
The suite moved to the oldest supported TimescaleDB, and found four things that day¶
The pin was meant to stop development being ahead of production. It earned itself in one run: four failures, of which two were real framework bugs that had been passing for as long as the image was newer than any real host.
Fixed: the primary key came back in the wrong order on PostgreSQL 17¶
SchemaBuilder::primaryKeyColumns() joined pg_attribute on
a.attnum = ANY(i.indkey) — a membership test, which says which columns are in the key
and nothing about their order — and had no ORDER BY at all. On PostgreSQL 14 the plan
happened to produce key order; on 17 it does not, and a composite key was reported
reversed.
The order is the whole contract: callers build WHERE clauses and chunk boundaries from
it. The MySQL branch beside it has always had ORDER BY ORDINAL_POSITION. Now
unnest(i.indkey) WITH ORDINALITY and ORDER BY k.ord.
Fixed: a migration's down() raced the scheduler¶
DROP MATERIALIZED VIEW applications.application_stats_hourly answered
tuple concurrently deleted — a message naming neither the view nor the job, which reads
like corruption. It is the refresh job running while the view is dropped.
SchemaBuilder::removeContinuousAggregatePolicy() is new, and the migration removes the
policy before dropping the aggregate, so there is nothing to race with. On a backend where
the policy is a software row instead, it deletes that.
Fixed in the tests, where the assumption was the test's¶
ContinuousAggregateRefreshTestlooked for the refresh job by the materialization hypertable alone. TimescaleDB changed which pairingtimescaledb_information.jobsreports — 2.19.3 the materialization hypertable, 2.26.4 the aggregate's own view — andSchemaBuilder::hasContinuousAggregatePolicy()already accepts both and says why. The test did not, so on 2.26 it reported "no background job" for one that had one.ForeignKeyOnCompressedHypertableTestasserted that TimescaleDB refuses a foreign key on a table with compressed chunks. 2.26.4 accepts it. Both outcomes are now reported — the difference is the engine's, not the framework's, and a skip guard that skips a key it could have added is conservative rather than broken. Itsfail()also sat inside thetry, where the exception it throws was caught by the method's owncatchand re-asserted as though it were the engine's, so the acceptance case complained about a message PHPUnit had written.
The continuous-aggregate fallback is removed: it could not tell two causes apart¶
It lasted one commit. PostgreSQL emits one ERROR: invalid continuous aggregate view
for two unrelated causes, and only the DETAIL distinguishes them:
ERROR: invalid continuous aggregate view
DETAIL: At least one hypertable should be used in the view definition.
— the source table has not been converted yet. A migration-ordering bug, fully fixable.
ERROR: invalid continuous aggregate view
DETAIL: <an expression that cannot be maintained incrementally>
— the case the fallback was written for.
The first is what a real installation produced, for all three of its aggregate migrations.
With the fallback in place all five would have become plain materialised views,
permanently, while migrate reported success — on a host where nothing was wrong with
them. Reproduced here in one line: a continuous aggregate over an ordinary table is
accepted by the framework without an exception and comes back a plain view.
Keying on the second DETAIL instead is not available: no expression this framework ships
has ever been refused by any version measured here, so there is no wording to match.
Excluding the first is the wrong direction — a reworded DETAIL in a later release would
silently degrade a customer's metric table rather than fail.
So the refusal reaches the caller and the migration fails, which is the correct answer for
both causes. health:check reports any view that is a plain materialised view while its
declaration says otherwise.
Added¶
ContinuousAggregateFallbackTestnow pins the ordering case: a source that is not a hypertable fails loudly, the message says so, and nothing is left behind — a half-created view would make the next run find the name taken and skip, turning the ordering bug permanent a second way.
A migration that did nothing for a missing capability was recorded as applied¶
The root cause of the three failing migrations, and the better finding of the two.
Every hypertable conversion the framework ships is wrapped in
ifCapable(TIMESCALEDB, …) with no fallback. Without the extension it does nothing — and
was recorded as Ran. migrate retries only what failed, so it never runs again.
The sequence that produced it, from a real deployment:
- The first
migrateran beforeshared_preload_librarieshad been set, soCREATE EXTENSIONhad never been possible. Every conversion recordedRanhaving done nothing. - The aggregate migrations in the same pass failed —
time_bucket()did not exist. - The extension was enabled and PostgreSQL restarted.
- The conversions were already
Ran, sotokenactions,authserver.user_activity_logandapplications.application_statsstayed ordinary tables. - The aggregates therefore kept failing, now with
At least one hypertable should be used in the view definition. timescale:ensureconverted the tables;migrateretried; all three succeeded unchanged.
"This capability is absent" and "this migration has been applied" were recorded as the same thing. A server that gains TimescaleDB later, or a database restored onto a host that has it, keeps a schema permanently behind what the history claims — and the repair is a command somebody has to know exists.
Fixed¶
ifCapable()records the capability it found absent when there is no fallback, and the runner turns that into a decline: recorded, reported, and retried on the next run, which is when the extension may have arrived. The mechanism already existed for a migration that declines on its own terms; this is the same shape.- A fallback that actually does something is not a deferral. That distinction is what the whole thing rests on: a migration that creates a plain materialised view where TimescaleDB would have an aggregate has been applied, and recording it as deferred would make it run again on every migrate, for ever, on a backend that will never have the capability.
- Nothing is lost on such a backend either — the migration is retried and declines again, cheaply, instead of lying once.
Added¶
MigrationDeferredCapabilityTest— the no-op is recorded, a fallback that does something is not, a present capability is not, and the decline message names the capability and says it will be retried. A result nobody can act on is a result nobody reads.
The TimescaleDB capability was answered from config, not from the database¶
DatabaseCapabilities::detectTimescaleDB() opened with a shortcut:
The probe below it is correct and never ran. 'timescale' => true in the settings is a
statement of intent — it selects the SQL grammar, which is right whether or not the
extension has been created — and it was read as "the extension is installed in this
database". Two different facts.
The gap between them is where a day goes. With the flag set and the extension absent,
ifCapable(TIMESCALEDB, …) answered true, the guarded block ran, and the migration failed
three layers down with relation "timescaledb_information.hypertables" does not exist —
recorded as a failure, not a decline. So the decline mechanism that shipped this
morning was unreachable for the one capability that motivated it, on exactly the
installations most likely to need it: packages present, config set, CREATE EXTENSION
never run.
Fixed¶
- The catalogue decides. The shortcut existed to avoid a query, and
has()already caches per connection in a WeakMap — so the probe costs one row, once, per connection, and answers the question that was actually asked. forgetDetected()is new, for the one case where the answer legitimately changes under a running process: the migration run below creates the extension.
…so migrate creates the extension itself now, or stops and says how¶
A scaffolded project declaring 'timescale' => true had to be told, by a person, to run
CREATE EXTENSION timescaledb before the first migrate. Nothing said so, and the wrong
order is punished silently: every hypertable conversion is guarded, so it is recorded as
applied having done nothing, and the schema ends up permanently behind its own history.
Measured on one installation — the wrong order produced 7 hypertables and 3 permanently
failed migrations; rebuilt in the right order, 10 and none.
The config flag is already a statement of intent. Acting on it is what it was always supposed to mean.
Added¶
migrateissuesCREATE EXTENSION IF NOT EXISTS timescaledbbefore the first migration when the application asks for TimescaleDB and the extension is absent. That succeeds outright in Docker and anywhere the application role is superuser — which is most development environments, and it removes the trap entirely for everybody who never touches a production database.- And where it cannot, the run stops.
CREATE EXTENSION timescaledbneeds superuser (it is not a trusted extension) andshared_preload_librariesmust contain it, which needs a restart. A role with neither is normal on shared and managed hosting — so the statement fails on precisely the installations that need the help, and the valuable half is the refusal. The message names both commands:
shared_preload_libraries = 'timescaledb' # postgresql.conf, needs a restart
CREATE EXTENSION timescaledb; # as a superuser, in this database
The damage was never the missing extension. It was carrying on without it and recording success.
TimescaleExtensionIsEnsuredTest— the extension is created when asked for, left alone when it is already there, not created when it was not asked for, and the run stops with both commands in the message when theCREATEis refused.
Written with a double rather than a real database, on the second attempt. The first version created and dropped a database per test, which in a full run exhausted PostgreSQL's connection limit and took nine unrelated tests with it — the same cost the performance guide has a section about, paid by the person who had just finished reading it. What is being decided here is whether to issue one statement and what to do when it does not take; the statement itself is one line and was verified by hand.
And the same race, at a second site¶
tuple concurrently deleted reappeared in create_applications_views.php's up() —
the path that replaces its own views — having been fixed in down() earlier today. The
refresh policy is removed before the view in both places now.
Forty tests that could not fail, and a guard that could not either¶
Most of what this month's reports found was found in production, not here. That is a statement about the tests, so this is a sweep for the shape of the ones that let it happen rather than for any one bug.
Forty fail() calls that could not fail¶
PHPUnit\Framework\AssertionFailedError extends \RuntimeException, so:
try {
$subject->mustThrow();
$this->fail('it did not throw'); // throws AssertionFailedError
} catch (\Exception $e) { // catches it
$this->assertStringContainsString('…', $e->getMessage());
}
passes whether or not the subject throws, and asserts on a message PHPUnit wrote. The one branch saying "this must not happen" is the one that cannot happen.
Forty of them, in twenty-four files, written by several people over a long time — including two written today, in two different files, by somebody who had fixed the first one an hour earlier. That is what makes it a guard rather than a review note: it is invisible at the point of writing and it reads correctly.
Every one of the forty turned out to be sound once it could fail. Nothing was hiding behind
them. The value is that forty tests now mean what they say, and
FailIsNotSwallowedTest keeps it that way — narrowly, because only \Throwable,
\Exception and \RuntimeException swallow the error. A sweep that flagged every catch
around a fail() reports 128 sites, of which 88 are correct code, and a guard nobody
believes is a guard somebody deletes.
Forty-eight of eighty-one templates are never run here¶
The framework ships far more code than it executes. Everything that went wrong in the scaffolding this month had that shape — a controller example that fatals at autoload, a webhook calling a package nobody requires, a form wrapper whose CSS framework removed the class — and none of it could have been caught by testing the generator, which was working: it emitted exactly what it was told to.
EveryStubIsSyntacticallyValidTest renders every stub and runs php -l or node --check
over it — 58 of the 81, the rest being markdown, CSS and Svelte (which has its own
block-balance guard). Three substitution rules cover all 118 placeholders: a line that is
nothing but placeholders is a block and disappears, a name that reads like a block
(postContent, schemaBlock, cssImport) disappears wherever it sits, everything else
becomes an identifier.
A floor, not a ceiling: valid PHP can still be wrong PHP — the $actions example that
fatals at autoload parses perfectly — so it sits underneath the guards that check meaning.
And the guard itself could not fail¶
Written, passing, and useless: it returned the parser's first output line and treated ''
as "it parsed". php -l prints a blank line first, so every broken template came back
empty and was reported as fine. It was found by breaking a template deliberately and
watching the guard pass.
A sentinel that can also be a real value is not a sentinel. Verify a new guard against the fault it is for — all three guards added today were, and this is why.
Three tests that had never run anywhere, and the supported-version claim¶
A skip is a test that is not running. Most of this suite's eleven are lane splits — the assertion belongs to MySQL and runs there, or to PostgreSQL and runs there — and those are correct. Three were not: they skipped in every run, on every backend, so nobody had ever seen them pass.
The container has every database in it, so "this connection has TimescaleDB" is a choice of connection, not a constraint. That was the whole objection, and it was right.
Fixed¶
McpTokenCommandTest::testTheStoredValueIsNotThePlaintextskipped for want of anAPP_KEY.Encrypter::key()reads the environment on every call and memoises nothing, so the test sets one and restores it in afinally—putenv('APP_KEY')with no=, because unset and empty are different environments and the rest of the suite was written against the first. The assertion is that a minted credential is not readable with aSELECT, which is exactly the kind that has to run somewhere.McpTokenGuardsTest::testWithoutABaseUrlThePlaceholderIsVisibleskipped becausesURLis a constant this process always has.McpToken::configuredBaseUrl()is a seam now, so the branch is reachable — and thehttps://your-siteplaceholder it prints has been seen produced for the first time.TimescaleEnsureCommandTest::testWithoutTimescaleItExplainsWhereRetentionComesFromwas skipped on the PostgreSQL lane and therefore only ever exercised on MySQL — a different driver and a different sentence, for a message whose whole point is naming the driver. It builds a database with no extension (TEMPLATE template0, because the image installs TimescaleDB intotemplate1) and runs there.
Eight skips remain, and each is a test that runs on the other lane or a documented version-dependent behaviour. None is a test that never executes.
The supported-version claim¶
The README said PostgreSQL 14+ while the suite now runs on 17 — a claim with nothing
behind it, created by this morning's pin. It says 17+ now, with the reason and with what is
known about 14: every test passed on it until the lane moved, so it is very likely still
fine. Likely is not verified, and an unverified claim is not a supported version.
docs/Pramnos_Schema_Builder_Guide.md said the same and says the same now.
Forty-five sweeps that would have passed over nothing¶
The third sweep for the same category: a test that passes whether the subject works or not.
When every assertion in a test sits inside a foreach, the loop body is the test. An
empty collection means no assertion is evaluated, and PHPUnit reports a pass — not even a
risky one, because the foreach line counts as work.
Both halves of the shape have already cost this project something. create:crud registered
every admin search source against a column called name for months: the helper picking the
columns destructured a pair into a single variable and returned [] every time, and the
test for it asserted "at most two" and "no empty strings" — both true of nothing. And a
glob() over a directory that briefly vanished during a run turned two theme sweeps into
no-ops in exactly the same way, on the same day.
Fixed¶
- Forty-five tests assert the collection is not empty before walking it. All forty-five
were in fact populated — as with the
fail()sweep, nothing was hiding — so the value is that forty-five guards can now fail when the thing they guard disappears.
Added¶
ASweepOverNothingDoesNotPassTest, restricted to aforeachover a call. A literal array or a variable built in the test cannot be empty by surprise; flagging those reports three hundred sites of which almost none is a risk, and a guard nobody believes is a guard somebody deletes. Verified by removing one of the forty-five and watching it fail.
Scaffolded code named three classes that do not exist, and the webhook fix was half applied¶
MissingClassReferenceTest resolves every fully-qualified \Pramnos\… name in src/, and
has done since a dashboard card rendered — / — / — for months because a plausible-looking
namespace was wrong and the Error landed in a catch (\Throwable) written for something
else. It reads src/ only. Everything the framework writes — the stub templates and the
heredocs in Init and MakeCommandBase — was outside it, which is the larger surface: the
framework runs a fraction of what it ships, and a class name that does not resolve is a
fatal on the consuming application's first request, not on ours.
Three were live on the first run.
Fixed¶
scaffolding/templates/webhook.php.stubcalled\Dotenv\Dotenv::createImmutable(). No such class: the framework loads.envthrough its ownloadDotenv()helper and does not depend onvlucas/phpdotenv. The same bug in the same file's other copy was fixed earlier today — the webhook is emitted fromInitand kept as a stub, and only the first was corrected. A fix applied to one of two copies is a fix that looks done and is not, which is exactly the class of thing a mechanical sweep exists to find.scaffolding/templates/CLAUDE.md.stubtaught\Pramnos\Http\Router::getInstance(). The router is\Pramnos\Routing\Router, it takes the application in its constructor, and it has nogetInstance(). The example is nownew \Pramnos\Routing\Router($this).- The same file taught
\Pramnos\Framework\Event::fire()/::listen(). The class is\Pramnos\Event\Event. Guidance a coding assistant reads is code it will write. Initemitted\Pramnos\View\Viewin three places, and a docblock still claimed the generated webhook "uses Dotenv for environment loading". The view class is\Pramnos\Application\View.
Added¶
GeneratedCodeNamesRealClassesTest, sweepingscaffolding/templates/*.stubplus the heredocs inInitandMakeCommandBase, asserting that every name under a vendor root the framework actually depends on resolves. It cannot use tokens the way itssrc/sibling does — a stub is a template, not loadable PHP — so it strips comments andnamespace/usestatements first. Without that it reported four things nobody could fix, and a guard that cries wolf is one somebody deletes.
The administrator a scaffold creates is Root now, not one tier below it¶
UserCreate::ADMIN_USERTYPE was 90, and the account init writes during scaffolding was
90 to match. Both are 99 — Root — now.
Not because 90 was locked out of anything. That is the tempting explanation and it is
wrong: UserTypes::DEFAULT_CAPABILITIES withholds admin.settings, admin.permissions and
devpanel below 98, but nothing in src/ calls UserTypes::can() yet. The screens gate on
minUserType and requiredUserType, and those top out at 90, so an administrator at 90
reaches every administrative screen the framework ships.
It is about which tier a fresh installation's owner should be given. On a scaffolded
application that account is the deployment's owner and usually the only one that will ever
exist, and 99 is the only tier UserTypes grants ['*'] — every capability, including ones
added later — against a fixed list at 90 and 98. Handing them the second-highest tier leaves
Root unused and says there is somebody above them, which on a fresh installation there is
not; and the first account of an installation is precisely the one that should not need a
hand-edit in the users table to keep up with an upgrade.
Fixed¶
UserCreate::ADMIN_USERTYPEis 99, andInit's admin-creation snippet reads the same number. The two paths to "make me an administrator" —initdirectly, andProjectSetupshelling out touser:create --admin— have disagreed once before, when the command said 1 andinitsaid 90.InitCommandUnitTestnow asserts the constant rather than the literal, so the next change to either fails here.
Documentation¶
- The Console Guide's "tier
--admingrants" section states the reason and, explicitly, states that the obvious reason is not it. A justification that does not survive reading the code is worse than none: it is what somebody cites when they raise the next floor. - A row in the Upgrade Guide. No account's
usertypechanges, and--usertype=90still produces exactly what--adminused to — but anything comparing=== ADMIN_USERTYPErather than>=now matches a different number.
A tracking write could sign somebody out, and a 401-character URL made it¶
sessions is a live-visitor list: one row per visitor, rewritten on every request with the
address they are on. url was varchar(255).
An OAuth callback is longer than that as a matter of course. Three Google scopes plus
state, code, iss, authuser and prompt came to 401 characters, and PostgreSQL
refuses the whole statement:
ERROR: value too long for type character varying(255)
INSERT INTO public."sessions" ("visitorid", "uname", …)
That alone would be a missing tracking row. What made it cost an afternoon is the handler:
both dialect branches of the middleware, and the deprecated addon it was extracted from,
caught the exception and called $session->reset() and $auth->logout(). A failed write
to a tracking table signed the visitor out.
So the screen at the end of a successful OAuth consent was the sign-in page. Everything
about that suggests an expired session, a cookie problem, SameSite, a token that did not
survive the round trip. Nothing about it suggests a column width — the error is in the
application log, several lines down, phrased as an insert failure with no mention of
sessions or of authentication.
The reasoning behind the logout can be reconstructed: if the row cannot be read, the
logout flag on it cannot be read either, so fail closed. It does not survive the
arithmetic. Failing open risks a revoked session surviving until the next request that
reaches the database — seconds. Failing closed signs out every visitor on any database
error, at the moment the database is already unwell.
Fixed¶
sessions.urlandsessions.agentaretext. The create-table declares it, and2026_09_17_000001_widen_session_url_and_agentwidens a database that already has them narrow — which includes every installation whosesessionspredates the migration system and skipped the baseline by cutoff. On PostgreSQL that is a catalogue change with no table rewrite.agenttoo: it has the same ceiling and was being truncated in PHP to stay under it, which is a workaround for a column width and can go with the width.- A failed tracking write is logged and nothing else, in the middleware and in the addon.
The forced logout is untouched: a row that is read successfully and says
logout = 1still ends the session, because that is a decision somebody made rather than an error. - The log line says what it is — "the visit was not recorded; the session is unaffected" — because the bare driver message is what made this hard to find.
Added¶
WidenSessionUrlAndAgentTest, on both lanes: the table is built narrow the way an old installation has it, the migration runs, and the catalogue is read back. Read frominformation_schemarather than by inserting a long value and seeing whether it survives, because MySQL's answer to that depends onsql_mode— a non-strict server truncates silently and the insert "succeeds".- Two tests in
SessionTrackingUpsertTest, so both lanes get them: a 401-character URL is recorded rather than refused, and a tracking write that fails leaves$_SESSION['logged']alone. The second one was verified against the old handler and fails on it. - Writing the first of those turned up something the class had never noticed:
getURL(false)builds its host fromSERVER_NAME, and with none set it returns the seven charactershttp://. Every existing test in that file had been recording that as the URL.
Eight controllers ended a request eight ways, and one of them ended the test run¶
/Debugbar renders its grant page and calls exit. A test that walked every controller
therefore truncated a suite at 126 of 198, printing the page's HTML where the summary
should have been — and a run that ends with no summary ends with exit status 0, which
every CI reads as a pass.
DevPanelController already had the answer: throw ApplicationClosedException under
PRAMNOS_TESTING. Going to copy it into DebugGrantController turned up seven more copies
of protected function terminate(), in four behaviours:
- five exited unconditionally (
Account,ApplicationsController,OrganizationsController,LogController,DebugGrantController); Adminerreturned under PHPUnit, detected withPHPUNIT_COMPOSER_INSTALL;DevPanelControllerthrewApplicationClosedExceptionunderPRAMNOS_TESTING;Oauththrew a bare\Exception('OAuth controller terminated')— which callers matched by string, including oneif ($ex->getMessage() === …)inside acatch (\Exception).
Each copy carried a comment saying it "can be mocked in tests". That is a seam, and a seam only helps somebody who already knows they need it. The ninth controller to end a request is written by somebody who does not.
Fixed¶
Controller::terminate()exists once, on the base class. It exits in production and throwsApplicationClosedExceptionunderPRAMNOS_TESTINGor either PHPUnit constant — three, because a project running PHPUnit without the framework's bootstrap is exactly the case that got this wrong. The message names the class, which is what tells you which controller stopped a run that stopped somewhere unexpected.- The eight copies are gone.
Adminer's docblock explained why its request must end completely — Adminer sends its own CSP policy and nothing else may run — and that reasoning moved to the call site, where it is about the call rather than about the mechanism. Oauth's generic handler no longer rethrows on a message string. The signal has a type and the typed catch above it fires first, so the comparison was matching nothing while reading as something that mattered.
Documentation¶
- A section in the Routing Guide: how a controller ends a request, why
exitis not it, and how to test one — expect the exception rather than mocking the method away. - A row in the Upgrade Guide. An application override declaring exactly
protected function terminate(): voidkeeps working; anything else is a fatal at class load, and a test matching'OAuth controller terminated'by string needs the type instead.