Skip to content

25 August 2026

30 changes:

  • The audit log gets a key that fits
  • A delete that holds the table
  • A policy you could create but never change
  • Two answers to "where do the views live?"
  • A model save reaches the browser
  • A socket that received nothing
  • Three tables, because retention is per table
  • A config key that did nothing
  • Compression that made it larger
  • The busiest table, and who calls it
  • A stale stat should not cost you the admin user
  • The page cache could not be switched on, and a hit lost its CSP
  • The scaffolded .gitignore had not kept up with what init writes
  • Every scaffolded .mcp.json named a file that was not there
  • A cache hit was telling the browser whatever PHP had already said
  • /login came with the site header on top of it
  • Every inline script was blocked and the report said "the button does not work"
  • The database password was in three committed files
  • A service worker that refuses to cache HTML
  • "I have just cloned this — what do I have to create by hand?"
  • The policy forbade the worker the framework had just started shipping
  • A browser detector with nothing underneath it
  • "No manifest detected", with the link right there in the source
  • A nonce on a data block buys nothing and costs the cache
  • The discovery controller had no address
  • The health report said ok, and no token worked
  • Three endpoints, three opinions about health
  • Two views that nothing could render
  • One long word came back as an ellipsis
  • The memory_limit raise that was lowering it

The audit log gets a key that fits

authserver.audit_log is created with a 64-bit key and, on TimescaleDB, as a compressed hypertable. Existing installations are not touched — deliberately, and that is most of what this change is about.

Changed

  • auditid is now bigIncrements rather than increments.
  • The primary key is (auditid, event_timestamp).
  • On TimescaleDB: a hypertable with 7-day chunks, compressed after 90 days, segmented by event_type and ordered by event_timestamp DESC.
  • No retention policy, and that is not an oversight.
  • log_audit_event() returns BIGINT.

Why existing databases keep what they have

The migration has always opened with hasTable() and returned. That guard now carries weight it did not before: everything above only happens on a database that does not already have the table.

Converting a live audit table means dropping and rebuilding its primary key and rewriting every row into chunks, under lock, on a table other things hold foreign keys into. That is not something a framework upgrade gets to do to somebody in the middle of a deploy.

For the same reason the table is not declared in HypertableRegistry. That is what timescale:ensure reads, and a declaration there would convert exactly the installations the guard exists to protect. The cost is real — timescale:ensure will not report drift on this table — and it was the better half of the trade.

No retention, and a test that says so

An audit trail is the one table where the framework deciding on its own to delete old rows would be wrong. The absence is asserted:

$this->assertSame(0, (int) $retention->fields['cnt'],
    'audit_log must have no retention policy: dropping audit rows is never the '
    . "framework's decision to make");

A retention policy is exactly what somebody adds later "for consistency with the other tables", and the damage shows up months afterwards, when the rows that mattered are gone.

Two things that would have broken it

PostgreSQL refuses to let CREATE OR REPLACE FUNCTION change a return type. Changing log_audit_event() to RETURNS BIGINT fails with "cannot change return type of existing function" on any database that already has the older one. A DROP FUNCTION IF EXISTS … CASCADE now precedes it — a migration that only works against a database which has never seen it is not a migration.

timescaledb_information.compression_settings has no segmentby column. It is a different view from the one the name suggests: one row per column, with index positions rather than expressions. The settings as declared live in hypertable_compression_settings. Cost one test run, recorded in the test so it costs nobody else one.

Scope

Checked before touching anything: no reader or writer of this table anywhere in src/, nothing in scaffolding/, nothing in app/ or bin/. The only writer is the SQL function above.

Documentation

  • Hypertable Guide — a new section on the one hypertable that is deliberately outside the registry, and what an existing installation should expect.

A delete that holds the table

PolicyEngine executes retention policies on every backend that is not TimescaleDB. It did it with one unbounded DELETE. Now it does it in batches, and the batch size is yours to set.

Fixed

  • Retention deletes in bounded statements, repeated until the table is clean or the run's budget is spent.

Added

$engine->register('retention', 'changelog', [
    'interval'    => '90 days',
    'time_column' => 'created_at',
    'batch'       => 5000,   // rows per statement; the default
    'max_batches' => 200,    // passes per run; the default
]);

Why an unbounded delete is worse than it looks

It is correct. That is the problem. An unbounded DELETE empties the table exactly as a batched one does, so nothing about the outcome distinguishes them and no correctness test can tell them apart.

What differs is everything around it: one statement holding locks for as long as it takes, inside a daemon, against a table the application is still writing to. The policy then reports ok, however long that was.

This matters now because a declared retention on MySQL runs through here and nowhere else — SchemaBuilder::addRetentionPolicy() registers a framework_policies row rather than a chunk-drop job when the extension is absent.

The test that had to be invented

Since the outcome is identical either way, the test needs a single pass to be observable. Making the batch size configurable — which is useful anyway — does it in twenty-five rows:

$this->seed(25, 60);
$this->registerRetention(['batch' => 10, 'max_batches' => 1]);
$this->engine->run();

$this->assertSame(15, $this->rowCount(),
    'one pass with a batch of 10 must delete 10 rows, not all 25');

Against the old implementation that assertion reads zero. It is the only one in the class that does; every other test passes on both versions.

Two backends, two statements

PostgreSQL has no LIMIT on DELETE, so the bounded form selects physical row ids first:

DELETE FROM t WHERE ctid IN (SELECT ctid FROM t WHERE  LIMIT n)

MySQL uses DELETE … LIMIT n. Different code, so both are under the same sixteen tests — a batching bug on one branch is invisible from the other.

The cap is deliberate

batch × max_batches is the most one run removes, and a table with more backlog is not cleared in one pass. The engine runs on a schedule and the rest goes next time. Holding the daemon for an hour on its first execution looks exactly like a hang, and gets it killed.

Both numbers are clamped. A batch of 0 from a config file would otherwise be a loop deleting nothing for two hundred passes while reporting success — the quietest failure available, and config files are exactly where a 0 arrives.

Documentation

  • Hypertable Guide — a new "Retention without TimescaleDB" section: what runs where, how to size the batches, and why the two backends emit different SQL.

A policy you could create but never change

HypertableRegistry has declared retention and compression intervals for seven tables since it was written. Changing one of those numbers did nothing, and said nothing.

Fixed

  • timescale:ensure compares intervals, not just presence. A declaration that no longer matches the database is reported as drift and repaired.
  • Every run no longer adds another policy on MySQL. hasPolicyJob() answered a flat false without TimescaleDB, so the guard never fired and addRetentionPolicy() inserted beside the existing row each time — N identical policies then issued the same DELETE N times against the same table.

Added

  • SchemaBuilder::policyInterval(), removeRetentionPolicy(), removeCompressionPolicy().
  • Per-table overrides from application config.
// app/app.php
'hypertables' => [
    'tokenactions'      => ['retention' => '10 years'],
    'pramnos.changelog' => ['compress_after' => '3 days'],
],

Why the number in the code was never the number in the database

if ($spec['retention'] !== null && !$schema->hasRetentionPolicy($table)) {
    $state['missing'][] = 'retention policy';
}

Presence only. Change '2 years' to '90 days', run timescale:ensure, and it reports nothing missing, changes nothing, and exits successfully. The two numbers then disagree for ever — and the one in the code is the one people read.

Nothing could have fixed it either: add_retention_policy() raises on a duplicate, which is why hasRetentionPolicy() existed in the first place, and there was no remove. Without a remove there is no replace.

The bias in the drift check

Comparing intervals means deciding whether @ 90 days and 90 days are the same thing. They are, and PostgreSQL will hand back either depending on where you read it from.

So the check normalises, and answers "no drift" whenever it cannot parse something. That asymmetry is the whole design:

  • a false positive removes and re-adds a policy on every run, for ever — constant work against the scheduler over a formatting difference;
  • a false negative costs one changed number not taking effect, which is exactly the situation this arrived to improve.

1 year 6 mons 3 days is therefore left alone rather than rewritten.

The MySQL duplicate

protected function hasPolicyJob(string $table, string $procName): bool
{
    if (!$this->capabilities->hasTimescaleDB()) {
        return false;
    }

A retention policy exists off TimescaleDB too — as a row in framework_policies, executed by the PolicyEngine daemon. Answering false made HypertableRegistry::apply() believe there had never been one, so every run inserted another. It now reads the software policy store, and addRetentionPolicy() updates an existing row rather than inserting beside it.

Documentation

  • Hypertable Guide — two new sections: retuning a declaration from app.php, and what drift repair does and deliberately does not do.

Two answers to "where do the views live?"

Two filings from a consuming application, both about the view layer assuming a layout instead of asking for one.

Added

  • View::$tplSubdirectory — empty by default. Set it to 'tpl' on a base view and templates resolve one directory down.

Fixed

  • Controller::getView() reads APPS_PATH when it is defined, falling back to ROOT/<INCLUDES> exactly as before.

The reporting application has 820 templates in 131 views/<module>/tpl/ directories — the legacy pramnos_application_view built that path, the modern View does not, and that single difference is why its 135 migrated controllers still construct legacy view objects. 135 controllers moved; 0 views.

The obvious fix is to try tpl/ whenever the flat path misses. Checking the actual convention first is what changed the answer: the reference application has no tpl/ directories at all, and neither do any of the three scaffolded themes. Flat is not an omission, it is the convention — and a fallback search would put a file_exists() on every render of every project, for ever, to serve a layout none of them use. It would also establish a second convention by accident, which the framework would then owe support for.

So the application that has the directory says so, once, and pays for it alone.

INCLUDES was not the problem the filing thought it was

The report said the framework hardcodes ROOT/includes/<app>. It does not — INCLUDES has always defaulted to src:

if (!defined('INCLUDES')) {
    define('INCLUDES', 'src');
}

The 'includes' came from the reporting application's own legacy bootstrap, which defines the constant before the framework can, and the !defined() guard honours it.

The other half of the filing stands, though, and is the real one: the fallback was built from INCLUDES, which describes where the code lives, where the legacy controller used APPS_PATH, which describes where the applications live. Translator\StringFinder already reads APPS_PATH; this was the one place in the framework answering the same question differently.

In a stock layout the two are the same directory, which is exactly why nobody noticed. When they are not, the fallback searches a path that does not exist — and finds nothing, which looks identical to a view that is genuinely absent.

Documentation

  • Framework Guide — two new sections under Views and Templates: templates in a subdirectory, and where the framework looks for an application's views.

A model save reaches the browser

ChangeBroadcaster connects the change feed to the channels the realtime stack already serves. With broadcasting enabled, a model that emits reaches a browser with no further wiring at all.

Added

// the model
protected $emitChanges = true;
protected $changeEntity = 'wcm-device';
PramnosEcho.private('wcm-device')
    .listen('model.changed', debounce(refetchList, 150));

Registered by BroadcastingServiceProvider::boot(). A registration each project has to remember is a feature that silently does nothing in most of them, and a listener costs nothing while no model emits.

The payload is identifiers, and that is the design

{ "entity": "wcm-device", "key": 42, "op": "updated" }

Not the record. Not the changed values. Not even the names of the columns that moved — "identifiers, plus which fields changed" is a half-rule to argue about later, and field names alone map a schema the API never exposed.

The subscriber refetches through the API, where permissions already apply. So no column can reach somebody the API would not have shown it to, no allow-list has to be maintained as columns are added, and a missed or rolled-back event costs one refetch returning current data rather than leaving a stale copy behind.

A model that declares $broadcastFields gets values and takes responsibility for the choice — after reading the multi-tenant channel warning, because with values on a subscriber on the wrong channel is a breach rather than a hint.

The test that matters most asserts an absence

$this->assertSame(
    ['entity' => 'wcm-device', 'key' => 42, 'op' => 'updated'],
    $sent['payload']
);
$this->assertArrayNotHasKey('data', $sent['payload']);

A test checking only "the message went out" passes on an implementation that publishes the whole record. That failure is silent, looks correct in every log, and is found by somebody reading a WebSocket frame.

It never sees the model

The channels and the allow-list are resolved when the change is emitted and carried on the ModelChange. Holding a model reference until a listener runs is the failure QueuedBroadcastableEvent documents — a stale copy of a row that may no longer exist — and a listener that can be queued must not be able to fall into it.

What this does not replace

A database-backed subscription sees every write. This sees writes through models: raw SQL, bulk queryBuilder() updates, migrations, WriteSpool and another service writing to the same database emit nothing. That is the one capability genuinely lost against Hasura, and the only equivalent is Postgres logical replication.

Documentation

  • Model Change Feed Guide — a new "Broadcasting model changes" section with the five-step Hasura replacement and what it costs.
  • Realtime Guide — points Model users at the feed rather than the Broadcastable trait, which is for OrmModel and must be called by hand.

A socket that received nothing

broadcast:serve is the process that turns a published event into a frame in a browser, and every application had to remember to supervise it. Now the framework does, when the configuration says WebSocket.

Changed

  • The orchestrator supervises broadcast:serve when broadcasting.transport is websocket. An application declaring its own entry keeps it; includeBroadcastServer() turns the framework's off.
  • broadcast:serve warns when --channels cannot deliver anything — a Redis ingest against a log, pusher or null backplane.
  • The command no longer describes itself as "local-dev".

The same accident, in a second place

DaemonOrchestrator already declares the framework's schedule worker, and its docblock records why: an installation supervising three application daemons and no scheduler ran none of the framework's periodic work, and every report reading a drained table showed "no data" for ever.

The WebSocket daemon was in exactly that position. Turn realtime on, forget the daemon, and every subscription is a healthy socket that never receives anything — the publish succeeded, the channel exists, the client connected. There is no error to find because nothing failed.

Only when the transport is websocket. An application on SSE needs no daemon, and one it never asked for would sit failing to bind a port and reporting itself unhealthy for ever, which is worse than useless in a dashboard.

The ingest mismatch that is left

The pairing rule — RedisDriver reads with SUBSCRIBE, RedisStreamDriver with XREAD — is already enforced by deriving the ingest from the driver rather than letting them be chosen separately.

What that cannot catch is an application publishing somewhere else entirely. --channels with a log backplane opens a healthy subscription to a Redis nobody writes to, and the symptom is identical to both the mismatch and to a working daemon with no traffic. It is now said at startup, where somebody is looking, rather than discovered from an empty browser hours later.

A docblock that was actively misleading

The command described itself as a "Local-dev WebSocket broadcasting server". It serves wss:// directly, clusters across nodes, authorises private and presence channels against AuthServer app keys, dispatches webhooks, and wires the orchestrator's cooperative stop.

Wording like that is how somebody concludes the framework does not ship a production WebSocket server and stands a second one up beside the working one. That has already happened once in this project with a debug panel, which is why the rule about guides describing current state exists at all.

Documentation

Three tables, because retention is per table

The changelog feature: enable it and every emitted model change is written down, at 0.003 ms a row.

Added

// app.php
'features' => ['changelog'],
  • pramnos.changelog — machine diffs, one row per save, kept 30 days
  • pramnos.changelog_events — things a person did, kept 2 years
  • pramnos.changelog_trace — stack trace and request context, kept 3 days
  • pramnos.changelog_history — a read-only view over the first two
  • Model::logEvent(), ChangelogReader, ChangelogRenderer

Why three tables and not one

A TimescaleDB retention policy drops whole chunks by time and takes no row predicate. One table can only ever have one retention.

The reference application reached the same conclusion the expensive way: its itemlogs was split in a repair migration on a live hypertable, after the automatic save log drowned everything else. Its numbers — machine noise for a month, semantic events kept, JSON payload far shorter — are where these start.

On an empty table the split costs nothing. On a live one it cost that project two migrations, one of which decompressed every chunk to widen a primary key.

The test that matters asserts they are different:

$this->assertCount(3, array_unique($intervals),
    'three tables sharing one retention is the design failing silently');

Three tables sharing an interval would pass every other assertion in the file while defeating the entire reason for the split — and the way that happens is somebody tidying three declarations into one.

No INSTEAD OF triggers

The reference application routes writes through a view with INSTEAD OF triggers, because it had hundreds of call sites naming one table and a migration that had to leave every one of them alone.

That view is a compatibility shim, not architecture. New code has no call sites to preserve: the writer targets the right table directly, and the view is read-only — which is also what makes it portable, since MySQL has no updatable views of that kind.

A round trip that nearly got in

The first version of the trace table keyed on the feed row's logid, and the writer generated one so the two rows could be linked.

That id has to exist before the row does, because the spool does not insert until the drain — so generating it meant a database round trip per change, inside the request, undoing the 0.003 ms append the whole design is built on. Caught while writing it, not by a test, because no test would have failed: it would simply have been slow.

The trace now carries (entity, itemid, created_at), which the feed already indexes.

Nothing stores prose

The feed stores a diff, an event stores a machine code, and ChangelogRenderer turns either into a sentence at read time — so wording changes without a migration and without reinterpreting rows written years ago, and so it can be translated.

The reference application renders from a switch returning hardcoded English keyed on two magic numbers. Same idea, frozen into PHP.

A description column exists for events no code describes. It should stay the exception.

Documentation

  • Model Change Feed Guide — a new "Writing changes down" section: the three tables, logEvent(), reading it back, and why traces are opt-in.

A config key that did nothing

'debugComment' => false sat in PageCache::defaults() and was read nowhere. Anybody who set it to true got nothing, and no explanation.

Removed

  • debugComment.

Added

  • 'debugDetail' => false — adds X-Pramnos-Cache-Key, X-Pramnos-Cache-TTL and X-Pramnos-Cache-Expires to a cached response.
curl -sD- -o /dev/null 'https://example.test/directory?utm_source=x' | grep -i x-pramnos

The key is the one that matters

X-Pramnos-Cache: HIT tells you the cache worked. It does not help at all when the cache is not working, and that is when anybody looks.

The question then is almost always "under what key did it go in?" — with ignoreQuery, varyBy and varyQuery all feeding the key, two requests you expected to share a page can quietly key differently, and nothing visible says so. The reporting application replaced an inline debug mechanism that printed the key, the TTL and the expiry, and had been using all three.

The header shows the key the entry is stored under, not one recomputed for the response. A recomputed key would agree with itself and disagree with reality, which is the single thing it exists to rule out.

Off by default, which the request did not ask for

The filing asked for these under debugHeader, which defaults to true. They are under a separate key that defaults to false instead.

HIT and Age are ordinary things for a cache to say. A cache key is internal, and publishing it to every visitor hands anybody probing for cache-key collisions the normalisation rules for free. An application that wants them everywhere sets one key; nobody gets them without deciding to.

Not a comment in the body

debugComment is removed rather than implemented, and the filing's own reasoning is why: a body is what snapshot tools diff and what a search engine indexes, and debug information does not belong in a stored page — which a cached page is, by definition.

A configuration key that does not exist is a clearer answer than one that silently does nothing.

Documentation

  • Page Cache Guide — a new "When you need to know why" section, and the diagnosis checklist now points at the key header rather than at recomputing it by hand.

Compression that made it larger

The changelog's segmentby was picked by reasoning about how TimescaleDB works. Measuring it produced a bigger difference than the reasoning predicted — and disproved the other half of the argument.

The measurement

2 M rows, 12 entities, 240 000 records over 30 days (tests/Benchmarks/changelog_compression.php):

segmentby chunk ratio stored compress per-row recent
entity 7 days 12.82 37.5 MB 5.8 s 11.0 ms 4.8 ms
entity 1 day 10.02 48.7 MB 4.6 s 12.7 ms 2.6 ms
entity, itemid 7 days 0.89 543 MB 74.6 s 16.8 ms 176 ms
entity, itemid 1 day 0.59 822 MB 133.6 s 2.2 ms 53 ms

A ratio below 1

Compression made the table larger. Not marginally: 822 MB against 37.5 MB for identical rows, and 133 seconds of CPU against 6.

TimescaleDB compresses in batches of up to 1000 rows per segment. A change log is sparse per record — one row changes a handful of times a day — so putting itemid in segmentby produces segments of a few rows each, far below that batch size, and the per-segment overhead then exceeds the saving.

That was the prediction. It was right, and understated: "compresses to almost nothing" is what the comment said, and the answer is "expands".

The half that was wrong

The spec said the chosen layout loses on "recent changes across an entity", and accepted that as the cost of a fast per-row lookup. It does not lose. It is 4.8 ms against 176 ms — thirty-seven times faster.

Written down here rather than quietly corrected, because a stated trade-off that turns out not to exist is the kind of thing that gets repeated in the next design by whoever read it.

What the other layout does win

entity, itemid at 1-day chunks takes the per-row lookup: 2.2 ms against 11.0 ms, because the segment is located directly rather than found by skipping batches.

It costs 22× the disk and compression that does not compress. Not the default, but the right answer for a log read constantly and kept briefly — which is what 'hypertables' => [...] overrides in app.php exist for.

The rule, which generalises

  • segmentby: columns you filter on that have few distinct values.
  • orderby: the high-cardinality column first — compressed batches carry min/max metadata for orderby columns, so a filter on one skips batches without decompressing them.

Documentation

The busiest table, and who calls it

The changelog measurement turned up a rule, and the rule pointed at tokenactions — one row per API request, kept three years, and the highest-volume table the framework declares.

Changed

  • tokenactions now declares segmentby urlid, method rather than tokenid, urlid, method.

Existing installations are unaffected. HypertableRegistry::apply() sets compression only on a table that has none, so this reaches new databases only.

It depends entirely on who calls the API

tokenid is high cardinality — one per issued token — which is the pattern that compressed a change log to a ratio below 1. But an API log is not a change log, and whether its segments are sparse depends on traffic shape. Both plausible shapes were measured, on 2 M rows over 60 endpoints and 90 days:

callers segmentby ratio stored by-token by-url
few, long-lived tokenid, urlid, method 6.95 36.8 MB 0.41 ms 0.65 ms
few, long-lived urlid, method 7.72 33.0 MB 6.83 ms 0.44 ms
many, short-lived tokenid, urlid, method 0.50 515.5 MB 5.44 ms 38.5 ms
many, short-lived urlid, method 6.76 37.9 MB 6.68 ms 0.46 ms

For a server-to-server API the shipped layout was right: 0.41 ms on "what did this token do", sixteen times faster than the alternative, and a healthy ratio.

For an API serving browser sessions it collapses — and the detail that settles it is that it does not merely trade disk for speed. At 0.50 it is storing 515 MB instead of 38 MB, spending 43 seconds compressing instead of 3, and answering the per-token query more slowly than the layout without tokenid in it. It loses on every axis at once.

Why the default changed rather than the documentation

A framework default cannot know which kind of API an installation runs, and the bad case is silent: nothing reports that compression is making a table larger. So the default is the layout that is never bad, and an installation that knows its callers takes the faster lookup deliberately:

'hypertables' => [
    'tokenactions' => ['segmentby' => 'tokenid, urlid, method'],
],

The price of the safe choice is a token-history listing at 6.8 ms rather than 0.4 ms. That is an admin screen, not a hot path — and the analytical reads go through the hourly continuous aggregate rather than this table.

The other six were checked too

Five declare no segmentby at all, which means one segment per chunk and large batches: no exposure. application_stats segments by appid and audit_log by event_type, both low cardinality. tokenactions was the only one matching the pattern.

A test that argues with a number

$this->assertStringNotContainsString('tokenid', (string) $spec['segmentby'],
    'tokenid is high cardinality: segmenting by it compresses a session-heavy '
    . 'API to a ratio below 1');

The existing assertion compared the whole declaration, which anybody changing anything about it would update wholesale. This one asserts the single property that matters and says why, so putting tokenid back has to argue with a measurement.

Documentation

  • Hypertable Guide — the table above, when to override it, and a note that existing installations keep the layout they were compressed with.

A stale stat should not cost you the admin user

init scaffolded a project, the dependency sync died on one arbitrary package, and everything downstream — migrations, admin user, API application — was silently skipped. The package was innocent.

Fixed

  • The in-container composer update now retries up to three times. Composer extracts every package into vendor/, which is a Docker bind mount of the project directory. ArchiveDownloader::install() creates the target directory, confirms it with file_exists(), then opens it with Finder — and Docker Desktop for macOS occasionally answers ENOENT for a directory it created a moment earlier:
Install of phpunit/php-code-coverage failed
In RecursiveDirectoryIterator.php line 48:
  RecursiveDirectoryIterator::__construct(/var/www/html/vendor/phpunit/php-code-coverage):
  Failed to open directory: No such file or directory

Nothing is wrong with phpunit/php-code-coverage — it is whichever of the ~30 packages lost the race. The retry is there because the consequence was so far out of proportion to the cause: a failed sync sets autoloadSuccess = false, so the framework migrations do not run, so the admin user is never created, and init reports success on a project that cannot boot. Three genuine failures still fail, and the closing summary still names the command to run by hand.

Documentation

  • Console Guide gains "The dependency sync retries — and why", next to the --no-install section, so the error above is searchable with its explanation attached instead of being read as a broken lockfile.

The page cache could not be switched on, and a hit lost its CSP

Three findings from a consuming application trying to turn PageCache on for its catalogue pages. The first stopped it working at all, the second was silent and security-relevant, the third was a default that never matched the cookie it needed to.

Fixed

  • PageCacheMiddleware reads app.php first. It read Settings::getSetting('pagecache') alone, so the pagecache block the guide has always shown in app.php was never seen: the middleware built a PageCache on defaults(), enabled stayed false, and nothing was cached and nothing said why. The settings store is still consulted when app.php has no block — and the value it returns is now accepted, which it was not: getSetting() casts an array to stdClass on the way out, and the old is_array() test discarded it. Both documented locations were dead, for two unrelated reasons.

The pairing — applicationInfo first, Settings second — is the one Application::lazySessionEnabled() already used for 'session' => 'lazy'. The same shape of question had two different answers.

  • A cache hit carries a Content-Security-Policy again. sendCspHeader() is called from Application::render(). A hit returns a Response before the application runs, so render() never executed and the page went out with no policy at all. It could not be replayed from the stored entry either, because the header never reached the Response — it goes straight out through header().

This is the worst shape a security regression has. The markup is right and the scripts run, and they run because there is no longer a policy to stop them; on a framework whose default is default-src 'none', a cached page had lost all of it. PageCacheMiddleware now builds a fresh policy and attaches it to the hit.

  • A response whose body contains this response's CSP nonce is never stored. The other half of the same problem, and the half a replayed header would have made worse rather than better: the framework stamps a per-response nonce into every inline <script> it writes, so a stored body freezes that nonce and hands the same one to every visitor for the whole TTL. A nonce that is reused is not a nonce.

So a page with nonced inline script and a page cache are mutually exclusive, and store() now takes the side that fails loudly: the page is simply never cached. An application that wants it cached has a clear instruction instead of a mystery — serve it without a per-response nonce (a file, a hash-based policy, no inline script), which is work only it can decide to do.

  • The session cookie is in bypassCookies by default. The default was ['#^(auth|remember|logged)#i'], which never matched PHPSESSID, so an application whose signed-in state lives only in $_SESSION had nothing on the list to stop it — and a signed-in response that sets no cookie is not caught by the Set-Cookie rule either. Measured in the reporting application: every public page differed when signed in, by up to 1,707 bytes.

It is session_name() rather than the literal, so a renamed session is covered. What makes it viable as a default is 'session' => 'lazy', shipped for this same cache: with it an anonymous reader carries no session cookie at all, so bypassing on one costs no hits. Without lazy sessions the framework set PHPSESSID on every response and store() already refused those — so this takes away no caching that was previously happening.

  • PageCache::serveEarly() reads app.php, and its hit carries a policy. The config was a required argument, so the pagecache block had to be copied by hand into www/index.php beside the one in app.php — two declarations of the same rules, and the early path is the one that answers first. Change bypassCookies in app.php, forget the copy, and the early serve keeps handing out a signed-in page from a rule set that exists nowhere else: the hole above, reopened by a stale copy.

Reading the file also gets the csp block, which is what lets this path send a policy at all — it has no Application to ask, which is the entire point of it. A require of an array literal is not the bootstrap serveEarly() exists to skip; what it skips is Application::init() and its database, session, language and theme. serveEarly($config) still works and still wins.

When there is no app.php to read, no policy is sent rather than a guessed one: the framework default is default-src 'none', and sending that to an application whose csp block adds hosts would break the page it was protecting.

  • A policy with no nonce omits the nonce source instead of emitting 'nonce-'. Browsers reject an empty nonce as an invalid source and drop it, which happens to be the safe direction — but it cost a consuming application a working night-mode button and two rounds of debugging, because a blocked inline script is present and correct in the response. Its own exec() override had stopped generating the nonce; the policy it produced said nothing about that.

Omitting is right rather than a workaround: Document\DocumentTypes\Html and Raw stamp a nonce into inline <script> only when there is one, so a response with no nonce has no nonced element for the source to match. This is the ordinary case on a cache hit.

Added

  • Application::cspPolicy(): string and Application::buildCspPolicy(array $csp, string $nonce = ''): string — the policy sendCspHeader() sends, as a value, for the callers that need to put it somewhere other than header(). The static one takes the csp block directly, because the caller that needs it most — PageCache::serveEarly() — has no instance by design.

  • Application::readApplicationConfig(?string $app): ?arrayapp.php as an array, with nothing constructed: no defines, no database, no session. null rather than [] when there is no file, because "there is no configuration to read" and "the configuration says nothing" lead to different decisions.

Documentation

  • Page Cache GuideTurning it on now says three things rather than two, because the third was catching people out: the pipeline has to return a Response, and Application::render() returns a string. The symptom of getting it wrong is identical to a config block that was never read, which is how it stayed unnoticed. New sections cover the CSP interaction and the session cookie, the bypass table's rule 7 and the configuration reference are current, and When a page is not being cached gained the two new answers. Serving before the application boots now shows the no-argument call and says what reading the file buys. The one path still without a policy is named as such: with 'writer' => 'static' a rewrite rule serves the file and PHP never runs, so the header has to come from the web server — which is all a static policy needs, the nonce half being provably absent from anything stored.

The scaffolded .gitignore had not kept up with what init writes

Read off a real scaffolded project rather than off the scaffolder: four kinds of generated file had no rule, and one docblock described a rule that had never existed.

Fixed

  • node_modules/ is ignored in every project. It was written by scaffoldSpaGitignore(), which only runs for a SPA — but one does not need a build stack to acquire the directory: npm install runs at the project root for the OpenAPI/RapiDoc generator, and ./dockernpm is scaffolded for every project. An MVC project with API docs on collected a few thousand untracked files and no rule saying they were expected.

  • The whole of var/ is ignored, replacing /var/cache/ and /var/logs/ by name. A real project also had var/migrations/*.verified — a per-database verification timestamp — and var/migrations-schemaversion.lock, a worker lock carrying a pid, a hostname and a heartbeat. Neither means anything on another machine, and naming directories one at a time is how the list fell behind in the first place. Nothing under var/ is source and every writer mkdirs its own directory, so a clone with no var/ is correct.

Documentation

  • Getting Started gains a table of every .gitignore entry with the reason for it, including the two files that are committed on purpose.mcp.json and CLAUDE.md are project configuration, and the point of them is that the next person to clone the repository gets them without being told.

  • A docblock in Init::scaffoldAiGuidelines() claimed ".mcp.json is added to .gitignore because it contains DB credentials". Neither half was true: the file holds a command and its arguments, and no code ever added it to .gitignore. Corrected rather than deleted, because the next reader would otherwise wonder which of the two was the bug.

Every scaffolded .mcp.json named a file that was not there

The MCP server shipped in every new project and could never start. Found by looking at a scaffolded project, not at the scaffolder — the test that covered this file was asserting the bug.

Fixed

  • .mcp.json names the CLI the project actually has. The stub hardcoded php ./bin/pramnos mcp:serve. That path exists in the framework's own repository and nowhere in a scaffolded project, where the CLI is <cliName>.php at the root and bin/pramnos lives under vendor/. Nothing failed: an MCP client given a command it cannot run simply has no server, so the symptom was a feature that was never there.

  • A Docker project gets the container form, with -T. Two separate reasons, both load-bearing. The database is reachable only from inside the container and mcp:serve is a database tool above all, so a host-side server answers every query with a connection error. And MCP speaks stdio over the pipe, so docker-compose exec without -T allocates a TTY the protocol never gets a clean stream through — which is also why the scaffolded ./<cliName> wrapper is not reused: it keeps its TTY on purpose, for interactive prompts.

{ "mcpServers": { "myapp": { "command": "docker-compose", "args":
    ["exec", "-T", "-u", "www-data", "app", "php", "myapp.php", "mcp:serve"] } } }
  • The test now asserts the file exists. The old one checked the rendered stub against the literal it had been written from — assertContains('./bin/pramnos', ...) — so it agreed with the defect for as long as the defect lasted. The replacement scaffolds a project and calls assertFileExists() on the script .mcp.json names, in both the Docker and non-Docker shapes. A configuration file no test executes cannot fail a suite; the only assertion worth making about one is that what it points at is there.

Documentation

  • MCP Guide shows both shapes and says why -T is not optional. McpServe's own docblock did the same thing the stub did — it used ./bin/pramnos as the example, correct in this repository and wrong everywhere it would be copied from.

A cache hit was telling the browser whatever PHP had already said

x-pramnos-cache: HIT next to pragma: no-cache, noticed in a running install. The headers were safe and nobody had chosen them.

Added

  • cacheControl — what a hit tells the browser. null by default, which leaves things exactly as they were.

What they were is worth stating. Pragma: no-cache, Expires: 1981 and Cache-Control: no-store, no-cache, must-revalidate come from PHP's session.cache_limiter, which defaults to nocache and fires on session_start(). A front controller calls $app->init() before the pipeline, so they are queued before the page cache is asked anything — and with 'session' => 'lazy' an anonymous visitor starts no session and gets none of them. Which headers a hit carried therefore depended on whether a session happened to start.

The accident was in the safe direction, which is why the default does not change it. A hit is a shared copy and no-store is the right thing to say about one: the browser will not keep the anonymous page and hand it back after the visitor signs in. The dangerous shape is the reverse — a hit carrying public, max-age=3600 lets a browser or a CDN keep the anonymous page for an hour and serve it to a signed-in user, and purgeUrl() can reach neither.

What the default costs is the second cache layer, so the knob exists for pages that really are public:

'cacheControl' => 'public, max-age=300',

Setting it also removes the leftover Pragma and Expires. Those have to go together: Cache-Control: public alongside a queued Pragma: no-cache is worse than either alone, because every HTTP/1.0 intermediary believes the Pragma. They are cleared with header_remove() rather than through the Response, which cannot unqueue a header it never carried.

Documentation

  • Page Cache Guide gains What a hit tells the browser, including the two things to be sure of before pointing a CDN at it — the bypass rules protect the server-side cache, not somebody's browser, and nothing here can purge a CDN.

  • Framework Guide gains It also changes the cache headers you send, under Declining the automatic session. That page described what 'session' => 'lazy' does to Set-Cookie and therefore to store(), and said nothing about the three headers session_start() also queues — so a reader turning lazy mode on for the page cache had no way to know it changed what their responses tell a browser. Two pages describing two halves of one mechanism is how the surprise happened; they now cross-link.

/login came with the site header on top of it

Reported from a scaffolded Tailwind project. The fix turned out to be a template the theme layer has been looking for since it was written, and which no theme ever shipped.

Fixed

  • The built-in auth pages render without the site chrome. /login arrived with the sticky site header, the whole navigation and a Sign in link pointing at the page the visitor was already looking at — and then, below all of it, a full viewport of centred card. That card is how every built-in auth view is written (min-h-screen under Tailwind, min-height: 100vh in the plain-CSS and Bootstrap themes), so the chrome was never intended to be above it.

Pramnos\Auth\Controllers\Account now calls setContentType('login') before rendering login, the second-factor step, forgot-password and reset-password, and a scaffolded theme ships app/themes/default/login.php.

The mechanism is not new. Theme::$elements has mapped 'login' to login.php since the class was written, and loadtheme() consults it before falling back to theme.html.php. No theme had ever shipped the file, so the fallback was the only path anyone had seen — which is also the compatibility guarantee: a hand-written theme with no login.php keeps rendering exactly as it did.

  • project:switch-ui writes it too, so switching UI system does not leave a login page loading the previous framework's stylesheet.

Changed

  • The head and foot asset lists are built once and used by both layouts, rather than copied. That is the whole reason the split exists: those lines change when the UI system changes, and a login page quietly still loading Bootstrap after a switch to Tailwind does not read as a bug — it reads as a design decision.

Documentation

  • Theme Guide gains login.php — the standalone layout, next to the content-type table, covering why <head> and <body> are written out explicitly, why renderCss()/renderJs() have to stay, and what happens to a theme that does not have the file.

The same table listed the login entry as login.html.php. The code has always said login.php, so anyone who had tried to use this would have created the wrong filename and concluded the mechanism did not work.

Every inline script was blocked and the report said "the button does not work"

Two findings about the CSP nonce, from two different applications. One was a real breakage with an invisible symptom; the other was a plausible claim that turns out not to be true, and worth recording for that reason.

Fixed

  • Application::render() generates the CSP nonce when exec() did not. exec() was the only place it was created, so any render that did not go through it produced a page whose inline scripts had no nonce, under a policy that then refused to run them.

An application overriding exec() is the ordinary way to end up there, and one did: $cspNonce stayed '' for the life of every request and every inline script on every server-rendered page was blocked. It was reported as "the night-mode button does not work", twice — a blocked inline script is present and correct in the response, on the right storage key, and nothing in a test suite can watch a browser decline to run it.

Deliberately not done on the page-cache hit path: there is no document to stamp, store() refuses to keep a body containing a nonce, and the policy omits the nonce source when there is none.

  • The no-js flip script was reported as missing its nonce. It is not — filed as FW-016 from a consuming application, and worth recording because the claim was plausible: the script is written inline into the <head> markup rather than registered through addScript(). Html::render() post-processes the finished document and injects the nonce by tag, not by registration, so that script has always had one. Verified against a rendered document, and now guarded by a test — the symptom the filing described (every page stuck in its no-JavaScript styling) is real, but its cause is the missing nonce above.

Documentation

  • Both are documented where the mechanism lives: Application::ensureCspNonce() carries the incident and the reason it runs where it does, and CspNonceReachesInlineScriptsTest carries the FW-016 answer as an assertion rather than as prose somebody has to find.

The database password was in three committed files

Read off a real scaffolded project. .gitignore had covered /.env from the beginning; there was simply nothing in it.

Changed

  • Scaffolded projects keep their secrets in .env. app/config/settings.php, app/config/testsettings.php and docker-compose.yml were all written with the database password in plain text, and 'development' => true beside it — so a deployment of that repository served debug output until somebody edited a tracked file on the server.

The settings files now read the environment and are identical in every checkout:

'password'    => (string) envvar('APP_DB_PASSWORD', ''),
'development' => envvar('APP_DEBUG', false),

docker-compose.yml interpolates from the same file — POSTGRES_PASSWORD: ${APP_DB_PASSWORD} — so a credential has one home and one place it can leak from. An unset variable interpolates empty and the database image refuses to start, which is the right kind of loud: a silently password-less database would be worse.

init writes .env with this machine's values and .env.example with the same keys and the secrets blank. APP_DEBUG is true in the first and false in the second — init is setting up a development machine, the next place that file is copied might not be one.

  • The keys are APP_DB_*, not DB_*. A real environment variable beats .env by design, so a platform that injects APP_DB_PASSWORD needs no file. That is also what makes the bare names dangerous: they are the ones a hosting image, a CI runner or a sibling container is most likely to have set already, for a different database, and the result is an application quietly connected to the wrong one.

Not a hypothesis. This framework's own dev container exports DB_HOST, DB_USER and DB_NAME, and the first run of the scaffolding tests after this change read pramnos_test out of a project configured for my_auto_app_db. If it can happen inside the repository that introduced the convention, it can happen on a host.

Defaults in the settings file are the non-secret ones only. A clone with no .env fails to authenticate — which points at the missing file — rather than failing to find a database.

Documentation

  • Getting Started gains Configuration and secrets: where the values live, why the keys are prefixed, why the password has no default, and what to do after a fresh git clone.

A service worker that refuses to cache HTML

init --service-worker=y writes one. Its design comes from reading a production service worker in a consuming application and cataloguing what had gone wrong with it — two incidents recorded in its own comments, and three more still live.

Added

  • --service-worker (default no) — scaffolds <web-root>/sw.js and the lines that register it, in the theme footer for MVC pages and in the SPA shell for SPA ones. It caches static assets in the browser: GET, same-origin, and only paths ending in a stylesheet, script, font or image extension.

Off by default on purpose. A service worker is the most persistent thing an application can install on somebody else's machine — it keeps itself alive across reloads, so a mistake in one is not corrected by the next deployment the way a mistake in a page is; the fixed page has to get past the worker first.

HTML is never intercepted, and that is the whole design. Once a worker caches HTML it needs a hand-maintained list of URLs never to cache — the signed-in pages, the checkout, the profile editor. The worker this is drawn from had eleven such entries, grown one at a time, and every page added to that application was a chance to forget one. The consequence is a visitor's personal page stored in a stranger's browser, where nothing on the server can reach it — the same failure the page cache's bypass rules exist to prevent, except those rules cover a store the application owns.

Two strategies, chosen by whether a URL can change meaning. assets/vendor/<lib>/<version>/ and the content-hashed SPA build are cache-first and never revalidated: a new version is a new path. Everything else is stale-while-revalidate, because assets/css/style.css never changes its URL and cache-first there is stale forever — which is how the original served a Maintenance Mode page through hard reloads for a day, having stored an error response as though it were a real one. Only response.ok is stored now.

There is no cache version to bump. The version prefix is what made that incident permanent: two of the original's three caches had unversioned names, so the sweep that deletes caches "not in the current list" could never reach them — a bump purged one and left the others stale for good. Nothing here needs one. Immutable entries stay valid by definition, everything else revalidates itself, and what bounds the cache is a cap enforced on write. Which also replaces a setInterval cleanup that could not run at all: a browser terminates an idle service worker long before a six-hour timer fires.

Two more corrections worth naming. The file sits at the web root, because a worker's scope is the directory it is served from and one under assets/ could only see assets/…. And the registration URL comes from sURL rather than a literal /sw.js, so an application in a subdirectory registers at its own path instead of 404ing — or, worse, claiming a scope above itself.

Documentation

  • New Service Worker Guide. Most of it is about what the worker refuses to do and why, including the two things it does not check — there is no Set-Cookie test, because that is a forbidden response header that fetch() never exposes, so the check would always pass and protect nothing; the path filter is what makes it unnecessary.

It also covers removal, which is the part people need in a hurry: deleting sw.js alone does not remove it from browsers that already have it — a 404 on the script unregisters the worker, but only when the browser next checks, up to a day later.

"I have just cloned this — what do I have to create by hand?"

Nothing answered that, and until today the answer was "nothing", because the credentials were committed. Moving them out of the repository was right and it left a hole.

Added

  • project:setup — brings a cloned checkout to a working local environment. init creates a project and refuses to touch one that exists; this one only ever operates on a project that already does.

Seven steps, each skipped when it is already done, so running it twice is safe and running it after a git pull is a reasonable way to catch up: .env from .env.example, docker-compose up -d --build, composer install, wait for the database, framework migrations, an administrator if you want one, and the front-end install and build when there is a front end.

Three of those deserve their reasons stated.

The host user ids are read from this machine rather than asked for. .env.example carries UID=1000 — the first non-root user on a Debian host, wrong on plenty of others — and getting it wrong means everything the container writes into the bind mount is owned by somebody who is not you. Nobody knows their own ids by heart.

An existing .env is left alone unless --force-env. It is the one file in a project that is not in version control, so overwriting it is the only edit here that git checkout cannot undo.

Waiting for the database is not a courtesy. docker-compose up -d returns as soon as the containers are created; a fresh Postgres or MySQL volume takes several seconds more to accept a connection. Migrating into that window fails with a connection error that reads exactly like a configuration mistake.

It writes no project file other than .env. Not one of its steps is a scaffolding step — project:reconfigure and project:resync own that — because a command that both set up an environment and edited tracked files would be one nobody could safely run on a checkout with local changes.

Changed

  • The spinner moved into a trait, Console\Commands\Concerns\RunsProcesses. Init had the only copy, and docker-compose up --build, composer install and migrate are the same commands whether a project is being created or a clone is being brought up. A second implementation would have been a second place for the slow-step escalation to be wrong — and that escalation is the part worth having in one place: a spinner that spins forever is indistinguishable from a hang, so after a threshold it stops spinning, says how long the step has been running, and streams the output. It was written because an image pull hung and there was nothing on screen to say so.

The trait declares explainDockerFailure() abstract rather than defaulting it to a no-op: a command that runs Docker and cannot explain a Docker failure is the situation it exists to prevent, and an empty default would let one be written by accident.

Documentation

  • Console Commands gains a project:setup section with the step table and the flags, next to the init options.

The policy forbade the worker the framework had just started shipping

init --service-worker=y wrote the file and the registration, and the framework's own default CSP refused it. Reported from a freshly scaffolded project as "I don't see it registering the worker" — which is precisely what it looked like.

Fixed

  • worker-src is 'self', and reads the csp block. It was 'none', hard-coded. That directive governs Worker, SharedWorker and the service-worker script, so the register() promise was rejected by the policy and nothing installed — a feature the scaffolder had just started writing, forbidden by the policy the same framework sends.

'self' is the tightest value that works: a browser will not accept a cross-origin service-worker script anyway. It gives up very little over 'none', since the only extra thing it permits is a same-origin new Worker(...), and reaching that needs a script already on the origin — at which point script-src 'self' has been defeated.

It is consulted from configuration now, like the directives around it. This is the second time a hard-coded value in that list has forbidden something an application could not then permit from app.php — the first was media-src, which silently blocked any <audio> or <video> whose source was not same-origin, with a console message naming a directive the policy did not contain.

  • A refused registration is reported. The registration snippet discarded the rejection, with a comment arguing that a browser which declines to register is simply a browser without the cache.

That was wrong, and it is what turned a one-line misconfiguration into a debugging session: the CSP refusal was the only signal, and it had been thrown away by the handler written to keep the console tidy. It is a console.warn now — for whoever is building the site, rather than an unhandled rejection that reads as a broken page to everybody else.

Documentation

  • Service Worker Guide gains CSP: worker-src has to allow it, plus the two other reasons registration silently does nothing and is not CSP's fault: navigator.serviceWorker is undefined outside a secure context, so http://192.168.… short-circuits the guard without logging anything at all; and a worker's scope does not extend above the directory it is served from.

A browser detector with nothing underneath it

Helpers::getBrowser() has always had the right signature. On a default PHP installation it filled one of its six fields, and said so to nobody.

Added

  • matomo/device-detector support in Helpers::getBrowser(), as a suggest. With it installed, browser, version, majorver, platform, os_number and engine are all populated — including os_number, which was hard-coded empty on the browscap path and empty on the fallback, so nothing had ever filled it.

Without it, behaviour is exactly what it was. A framework should not put a user-agent parser into every project that installs it.

  • detector on the returned object: device-detector, browscap or sniff. An empty version used to mean either this agent is not identifiable or there was no parser running, and those call for opposite responses — a fact about the visitor, or a missing package. The field is how a caller tells them apart, and it is what the filing behind this asked for.

Why

Filed as FW-017 from a consuming application, with numbers. get_browser() needs the browscap ini directive pointing at a browscap.ini, and that directive is unset on a default installation — so the real engine was the fallback, a six-branch regex returning a name and nothing else. The method answered with a perfectly valid object every time, which is what made it invisible: 3,040 visits recorded with a browser name, 771 with an operating system or an engine.

matomo/device-detector rather than browscap/browscap-php for one decisive reason: its regexes ship inside the package. No data file to provision, no monthly refresh, and no way for a missing download to degrade it silently — which is the same failure mode as the one being fixed. Staleness becomes composer update.

Two mappings are deliberate. platform stays the operating system, because device-detector's own platform is the CPU architecture and passing it through would have quietly changed what a public field means for every existing caller. And a crawler gets a name and nothing elseGooglebot with an empty version — because this object is written into statistics tables a row at a time, where an invented number is worse than an empty one.

Fixed

  • getBrowser() reaches its helpers through static:: rather than self::, so the parser is a real seam an application can substitute. With self:: an override bound to the base class and was ignored — which the tests found immediately, since the fallback path can only be exercised on a machine where the package is installed.

Documentation

  • Framework Guide gains Reading a user agent: the three engines, what each one fills, when to install the package, and what detector is for.

Reported from a freshly scaffolded project. The manifest was written, linked and served with a 200 — and no browser had ever read it.

Fixed

  • The theme's head assets reach the document's <head>. theme.html.php had no <head> tag, so Theme::getheader() — whose only job is to lift <head>…</head> out of the theme and hand it to the document — always returned an empty string. Everything the theme emitted went through gethead() instead, which the document writes after <body>.

It went unnoticed for as long as stylesheets were the only thing in there: a browser hoists <link rel="stylesheet"> out of the body and applies it. It does not honour <link rel="manifest"> outside <head>. So the favicon block, the manifest link and the Windows tile config were all in the wrong half of the document, and the only visible symptom was devtools reporting No manifest detected about a link anybody could see in the page source.

theme.html.php now writes <head> and <body> out, and includes a new head.php element for the document head. The <body> tag is the other half of the fix: it is what stops the split at [MODULE] from emitting the head assets a second time as page content.

  • head.php and header.php are separated, which is the distinction the single file lost. head.php is the document head — stylesheets, favicons, the manifest link, renderCss(). header.php is the visible site header, the logo and the navigation. Only one of them can go in <head>, and putting both in one file guaranteed that neither did.

A hand-written theme with neither tag keeps rendering exactly as it did.

The same shape as login.php, which was written with both tags a few commits earlier — and reading why it needed them is what turned this report into a diagnosis rather than a search.

Documentation

  • Theme Guide gains head.php, and why <head> has to be in the layout, next to the standalone login layout it mirrors.

A nonce on a data block buys nothing and costs the cache

Two inline scripts the framework itself emitted were the only thing keeping an otherwise static page out of PageCache. Neither could use the nonce it was given.

Changed

  • A <script> whose declared type is not JavaScript no longer gets a nonce. script-src gates script execution, and a <script type="application/ld+json"> is a data block the browser never runs — there was nothing for the policy to allow, so the nonce was inert. Embedding data in application/json is a well-known way to sidestep CSP for exactly this reason.

Harmless until PageCache::store() began refusing any body carrying the request's nonce, because a nonce reused across visitors is not a nonce. From then on an inert nonce was the difference between a page that could be cached and one that could not. Filed with measurements: after a consuming application moved its own inline script into a file to comply, what was left on its catalogue pages was 248 bytes of JSON-LD and 96 bytes of framework <head> script — both the framework's, neither removable by the application.

  • importmap and speculationrules keep their nonce, and the filing was wrong about them. It listed both as non-executable alongside application/ld+json. They are not: an import map needs an inline allowance like any other script, and speculation rules are gated by script-src so specifically that CSP has a dedicated 'inline-speculation-rules' keyword for them — other frameworks have open issues about precisely this.

Following the list literally would have broken both under a nonce policy, silently, because nothing reports it until somebody first tries an import map. So the decision is an allow-list of executable types, not a deny-list of data ones: wrong in the allow-list direction costs an unnecessary nonce, wrong the other way costs a working page. Any type naming javascript or ecmascript in a spelling the list happens not to carry keeps its nonce too.

Inline <style> is unchanged. style-src genuinely gates inline styles, so those nonces are doing work.

  • The no-js flip is allowed by hash instead of by nonce. The 96 bytes in <head> that turn class="no-js" into js are a fixed string, so script-src now carries 'sha256-…' for it and the tag goes out without a nonce. It is frequently the only inline script on a page, so nonced it was the whole of what stood between an otherwise static page and the cache.

A hash rather than an external file, because the script has to run before the first paint: a blocking request in <head> to answer does JavaScript exist is the very thing the no-js class exists to answer without one. The hash is computed at runtime from the constant the script is emitted from, never written down — a hash and the bytes it covers must agree exactly, and a hardcoded one would go stale the moment somebody edited the script, as a blocked flip and a page permanently in its no-JavaScript styling.

unsafe-inline still suppresses both the nonce and the hash: a browser ignores unsafe-inline as soon as either is present, so emitting one would quietly cancel what the application asked for.

Together with the change above, a scaffolded page with no inline script of its own is now cacheable out of the box rather than after an audit of the framework's markup.

Fixed

  • The nonce injector is one implementation on Document, not two identical copies in Html and Raw. For a security-relevant regex that has to agree with itself, one copy was one too many — and this change had to be made in both.

Documentation

  • Page Cache Guide gains which of your inline scripts actually needs a nonce, and When a page is not being cached now hands over the one-line diagnostic:
curl -s https://example.test/the/page | grep -o 'nonce="[^"]*"[^>]*' | head

Everything it lists is the application's own, which is what makes it actionable.

The discovery controller had no address

init has scaffolded a Discovery controller for every authserver project for a while now. Its docblock names the endpoints it serves — /.well-known/openid-configuration, /.well-known/jwks.json, /.well-known/oauth-authorization-server — and every one of them answered 404, because the .htaccess init wrote in the same run had no rule that could reach them.

Fixed

  • The .well-known paths reach Discovery. Those paths are fixed by specification, so they do not fit the framework's controller/action URL shape and cannot be routed by accident. init now writes the five rules — including the underscore spelling of openid_configuration, which is in no specification and in plenty of clients — whenever the authserver feature is on, and omits them entirely when it is not, since they name a controller only that feature scaffolds.

They go above the catch-all rule. mod_rewrite runs rules in order and the catch-all matches every path, so a discovery rule below it never fires — a bug that would leave the rules present and the endpoint still broken. There is a test asserting on positions rather than on presence for exactly that reason.

  • The Authorization header reaches PHP. Apache does not pass it to PHP-FPM or CGI unless it is copied into the environment, and it was not being copied. Every request authenticating with Authorization: Bearer … — which is every generic HTTP client, every OpenAPI console, every curl in a support ticket — arrived looking anonymous. That reads as a rejected credential, so the investigation goes into the token; the token was fine.

This one is written for every project, not only authorization servers. Any REST API that takes a bearer token needs it.

Both blocks were missing from all three application styles, and each style wrote its own web root config, so the fix is one shared helper rather than three parallel edits. A SPA project was the worst affected: its shell fallback answered a discovery request with the application's HTML and a 200, so a client saw malformed JSON instead of a missing endpoint.

Documentation

  • Third-Party Integration gains two troubleshooting sections — "If discovery answers 404" and "If a bearer token reads as no token" — with the rule block and the ordering constraint spelled out.
  • Console describes what init now writes into the web root config, and that all three application styles get it.

A project scaffolded before today keeps its own .htaccess: version control does not update it, so the block has to be added by hand, above the catch-all.

The health report said ok, and no token worked

An authorization server that has lost its private key answers every page normally. The database is reachable, the disk has room, memory is fine — and every /oauth/token request returns a 500. /health/check reported ok on exactly that server, because nothing in it was looking at the one file the whole feature depends on.

Added

  • Pramnos\Auth\Health\SigningKeysCheck, registered automatically by AuthServerServiceProvider when the authserver feature is on. Nothing to wire: it appears in /health/check, on the dashboard, and in health:check on the command line.

It does not ask whether the key files exist. file_exists() is true in every state below, and the server cannot issue a usable token in any of them:

State Result
A key missing, unreadable, or a directory where a file should be down, naming which half
A key present but unparseable — a truncated write, a mangled PEM down
Two valid keys from different pairs down
A matching pair below 2048 bits degraded

The mismatched pair is the case that justifies the rest. Both files parse, both are real keys, every file test passes, and no token this server signs can be verified by anybody — so the failure surfaces in somebody else's application, days later, as "your tokens are invalid". The check signs a constant and verifies it, which rules that out for the cost of one small signature.

Undersized keys report degraded rather than down on purpose. A 1024-bit key signs RS256 perfectly well; calling that an outage pages somebody about a working server, and calling it ok means it never gets rotated.

  • OAuth2ServerFactory::defaultPrivateKeyPath() / defaultPublicKeyPath() — the default key locations, which were an expression inside the constructor. Now that a second thing needs to know where the keys are, a second copy of that expression would be a copy that drifts, and the drift would show up as a health check reporting confidently on a file the server does not sign with.

Documentation

  • New guide: Health checks. The health system had no guide at all — it was described only in the frozen v1.2 reference, which is precisely the state rule 1 exists to prevent. The page covers the endpoints and their status codes, what is registered without writing anything, how to write and register a check, why run() must never throw, how to choose between degraded and down, and the signing-key check in detail.

One thing in there is worth repeating outside it: degraded answers 503. A monitor that reads only the status code treats reduced capacity as an outage. That is the safer default, but read status from the body if you want the two apart.

Three endpoints, three opinions about health

/health/check ran every registered check. health:check on the command line ran them too. And /.well-known/health ran a SELECT 1 of its own and reported on that alone — so it was the only one of the three that could not see a full disk, an unreachable cache or a missing signing key. Three probes are three answers, and the interesting day is the one they disagree on.

Fixed

  • /.well-known/health reads HealthRegistry. Its response shape is unchanged — status, timestamp, and a components map — but components now lists every check the application registered instead of a hardcoded pair, so it grows with the application rather than describing a subset of it. The three-way ok / degraded / down collapses to the ok / error this endpoint has always spoken; a caller that needs the distinction has /health/check.

One guard came with it, and it is the reason this was not a two-line change: HealthRegistry::runAll() on an empty registry answers ok. A controller reached from a script, or from a boot that registered nothing, would therefore have reported an authorization server with no database as healthy — strictly worse than the SELECT 1 it replaced. So the endpoint ensures a database check is registered before it runs, and treats a missing database verdict as a failure rather than as silence.

Added

  • GET /health/status — the same verdict as /health/check, flattened to {status, timestamp, service}, plus the names of the failing checks when something is wrong.

Two reasons it exists next to the full report rather than instead of it. Some probes cannot read a nested document — a load balancer check, a status page widget, a shell script wants one field, and asking it to walk checks.*.status is how a probe ends up parsing JSON with grep. And /health/check publishes versions, drivers, paths and latencies in details: a fair trade on a private network, less so on an endpoint reachable from the internet, where a database version and a driver name are a starting point for somebody looking for one. This gives away whether the application is well and where to look — what an operator needs, and all an attacker gets.

It does not re-probe: both endpoints read the same runAll().

  • GET /Discovery/serverConfig — a summary of the server built for a person rather than for a client library. The URLs, the grants that work here, the scopes that exist, which optional features are on. The page you paste into a ticket.

It is explicitly not a standards document — /.well-known/openid-configuration is, and a client should read that. What matters about this one is that every list comes from whatever actually decides it: Scopes for the scopes, app.php features for the flags. A hand-written integration note goes stale silently; this cannot.

Documentation

  • Health checks documents both JSON endpoints, when to prefer the flattened one, and why degraded answers 503.
  • Third-Party Integration gains "A summary built for a person" and "Is the server up?".

Two views that nothing could render

Every bundled theme has shipped register/register.html.php and sso/sso.html.php. Neither had a controller. The registration form posted to Home/register, a route the scaffold does not create, and the discovery document advertised registration_endpoint as /register — a 404 with a promise attached.

Added

  • Account::register() and the /register route, scaffolded by init with the auth feature. Closed until auth_allow_registration is switched on: a scaffolded application must not gain a public sign-up page by being upgraded, and most applications on this framework create their accounts by some other route entirely. With it off the page renders and says so, rather than 404-ing a page the views link to.

The guard order is the security story, and it is deliberate: the registration switch is read before the request body, so a crafted POST to a closed server cannot write a row; CSRF is checked before validation, so a form without a token is not even an account-existence oracle; and every field is validated before any query, so the endpoint is not a way to make the database work for free.

The password rules are the ones resetpassword already enforced — eight characters, a digit, a symbol — because there is one policy and it lives in one method. The forms were advertising minlength="6", which is a form accepting what the server rejects, then sending somebody back to the page that had told them they were fine.

registrationIsOpen(), validateRegistration(), usernameExists() and createUser() are all seams, so an invite code or a domain allow-list is one overridden method rather than a reimplemented flow.

On enumeration, stated plainly rather than papered over: "that username is taken" confirms an account exists, and a form that has to let somebody pick another name has to say why. It reveals that. The mitigations are leaving registration off when you do not need it and keeping the login lockout, since the value of an enumerated username is what happens next. The email case is worded so that it does not add a second confirmation.

  • Account::sso() and the /sso route — the page that answers "does this server already know me, and what have I authorized?". Public, because for a signed-out visitor that negative answer is the useful half.

Fixed

  • Eighteen bundled views linked to routes the scaffold does not create. Home/login, Home/register and a bare logout were all dead; the real routes are /login, /register and /login/logout. A dead link in a scaffold is worse than a missing feature, because it looks like a feature until somebody clicks it. There is now a test that walks every view in every theme looking for exactly these three.

  • The SSO page rendered every application without its link. The view was documented as receiving website_url; getAuthorizedApplications() never selected it. It does now.

Documentation

  • Authentication gains "Self-service registration" — the switch, the enforcement order, the enumeration trade-off, and how to gate sign-up on an invite code instead — and "The single sign-on status page".
  • Account & Security gains "Creating an account", for the person reading it rather than the person wiring it.

One long word came back as an ellipsis

Helpers::shortenText() lost the entire text whenever there was no space inside the limit. Reported as FW-018 with the strings measured, and found here independently while moving the method somewhere it could be found.

Added

  • StringHelper::excerpt(?string $text, int $length, string $ellipsis = '…') — a plain-text excerpt of at most $length characters that never splits a word. Three guarantees, each of them a bug it removes:

A word longer than the limit is cut, not lost. mb_strrpos() found no space, returned false, mb_substr() read that as 0, and the result was the ellipsis on its own. Measured in the reporting application:

'Καθηγητήςμαθηματικών'               len=10  ->  '&hellip;'
'Supercalifragilisticexpialidocious'  len=12  ->  '&hellip;'
'Παιδαγωγός'                          len=5   ->  '&hellip;'

A Greek compound, a name with no space, a URL and a hashtag are all that shape, and the method was called in 20 places there — all of them user-facing lists. The symptom was not an error; it was a column of "…" where titles should be. The legacy framework this was ported from had exactly this guard and the port dropped it.

The result never exceeds $length. The old version cut to $length and then appended the suffix, so a caller sizing a column or a meta description could not rely on the number it passed. Console\CommandBase::truncateText() had this right all along, which the filing also spotted — the framework contained the correct answer and the wrong one, one layer apart.

HTML is stripped before measuring, so the length is a length of visible text.

Changed

  • Helpers::shortenText() is a deprecated alias and forwards, so there is one implementation. Two behaviours change for existing callers — both of them the fixes above — and the default suffix is now the character rather than the entity &hellip;. It renders the same in HTML and is correct where the entity was wrong: a plain-text email, a JSON field, or anything that escapes the result and turned it into a visible &amp;hellip;. It also has to be one character now that the length includes it, since charging eight for an ellipsis leaves almost nothing of a short excerpt. A suffix passed explicitly is used and counted literally.

$charset is ignored — it was utf-8 at every call site, and excerpt() uses the internal encoding.

Why not symfony/string

It is already installed, so the question was real. Measured rather than assumed: its truncate($length, $ellipsis, cut: false) guarantees the opposite — it extends to the next word boundary, so a limit of 5 on The quick brown fox returns ten characters, and a single long word comes back whole and unmarked. Useful when you want at least $length; not when the bound is the point.

Documentation

  • Framework Guide gains Shortening text: the guarantees, the alias's two behaviour changes, and why CommandBase::truncateText() is not a duplicate — it measures visible width, ignoring ANSI codes, and it splits words, which is right for a terminal column and wrong for prose.

The memory_limit raise that was lowering it

Four tests in a long-running suite reported a PHP warning. The warning was right, the code was wrong, and on a generous host the effect was the opposite of what was intended.

Fixed

  • ResizeTools raises memory_limit and no longer lowers it. Resampling with a fill colour set ini_set("memory_limit", "256M") unconditionally before imagefill(). On a host configured with more than 256 MB that is a reduction, so the fill ran with less memory than the request already had — the exact failure the raise exists to prevent. And once the process was already using more than the new value PHP refused it outright:
Failed to set memory limit to 268435456 bytes (Current memory usage is 279969792 bytes)

It now parses the current limit and does nothing when it is unlimited or already at the floor. Raising only also makes the call unable to fail: usage can never exceed the current limit, so a new limit above it is always above usage.

  • Two try/catch blocks that could never fire are gone. Both wrapped ini_set() and logged a caught \Exception. ini_set() does not throw — it returns false and raises a PHP warning — so the handlers were unreachable, the warning went unhandled, and the code read as though failure were covered. That is the part worth naming: a guard that cannot fire is worse than none, because it stops anybody from looking.

  • Helpers::parseMemoryLimit() is public, so both callers share one parser rather than one having a private copy and the other a hard-coded literal.

Tests

Five, and one of them is a lesson. The end-to-end test drives the real path — a fill colour, an unlimited limit — and fails on any PHP diagnostic, which is what would have caught the original.

The raise-path test asks for a floor above the current limit rather than lowering the limit below the floor. The first attempt did the latter, 64M against a 256 MB target, and produced the very warning this change removes: a suite already using 179 MB cannot be told its ceiling is 64 MB. Reproducing a bug inside its own test is a quick way to learn that the arrangement, not the code, was causing it.

Documentation

  • Media Guide gains memory_limit while filling a thumbnail: what the floor is, that a host above it is left alone, and what to do if 256 MB is not enough.