16 October 2026¶
Forty-nine entries today:
- The DevPanel's policy callback decides the development route only
schedule_taskrequires a kind- A page left open refreshes its CSRF tokens
- Dashboard cards: four widths, settings, and drawn one at a time
- A reminder asked for in a new conversation leads back to it
- Dashboard cards are dragged and resized with a finger as with a mouse
- A page for scheduled AI tasks and shared reports
- The SPA's Scheduled screen: every kind and cadence, runs, shared reports
- Deleting an account hands its tasks over and erases what the assistant kept
- The dashboard's catalogue, fields, and figures only when read
- A partial falls back to the bundled scaffold, as a template does
- Dashboard drag that a theme cannot break, by the title too; flash messages translated
- Scheduled tasks for the analyst, and the right time shown on MySQL
- A card's title row keeps the pointer
- The analyst's scheduled page: its buttons, its floor, its words and its links
- Four more dashboard cards: queue, email, AI spend, errors
push:setupfinds the worker it is told about, and checks the icon and the routing- Every enqueued script and stylesheet is on the page once
- Hooks for the scheduled page, read-only AI cards, a push switch, and dashboard cards chosen by id
- A shared report's maker chooses its readers
- Push subscriptions need this site's token; push.js speaks the page's language and survives a replaced URL; admin pages' assets are not cached
- Navigation preload, a worker address that versions itself,
mtimeasset versions, and a stored miss with its Cache-Control - The sign-in credential is a token per device, not the password hash
- Readers' roles from the application, a hook when they change, translated refusals and card labels, cards tuned from app.php
- Core Web Vitals from real visitors
- Shared reports by email, and a task's model calls marked as its own
- One broken browser can no longer fill the PHP pool with error reports
- A dashboard card's period, set by each person
- Every message channel on one card
- What's new, from the help, on the dashboard
- Browser errors of extensions and other hosts' scripts are not kept
- A sign-in token is spent where the hash was expected, and "log in as" ends the token it replaces
- Who is online now, and signing one session out
- The page cache: cookies PHP queued, the remember-me cookie, and a CDN header
- Signing in as somebody else, and back without a password
- The worker's immutable addresses from its registration, and the page cache's Pramnos-Page signal
- A login marker cookie
- Speculation rules
- Public pages in the service worker
- A calendar: FullCalendar in the catalog, and pf-calendar.js
- Signing a session out by its sid, and a page whose controller rewrites the query
- Cropping and resizing a picture before it is uploaded, on the profile picture card first
project:installregisters in code, and TestClient keeps the application's registrations- The activity log writes wherever its table is,
oauth:keys, and a registered client never half-stored - An audit trail you can write and read, and the crop dialog in a real browser
- Which OAuth scopes a person may grant, by role
- The crop dialog zooms with a slider
- The audit log's moments on MariaDB, objects beside targets, exclusions and a retention per event
- No offset strings into MariaDB: the language-model tables, the push log and the activity log
The DevPanel's policy callback decides the development route only¶
Documentation¶
- A policy callback set by a
DevPanelControllersubclass replaces the development route's usertype floor, and nothing else. An accountapp.phpadmits on any deployment (therootrole,usertypes,userids) is let in before the callback is asked, so a callback that says no does not keep root out.- This was always the behaviour. It is now stated in the DevPanel Guide and on the property.
- To keep an account out, narrow the lists.
schedule_task requires a kind¶
Fixed¶
- A reminder no longer becomes a question. Asked "remind me in 2 minutes: …", a model called
schedule_taskwithoutkind. The task was made asask, and two minutes later a model was asked the words instead of the person being reminded of them.kindis now required in the tool's schema, and a call without it is refused with an error that says which kind is which.- The model then calls again with the kind.
A page left open refreshes its CSRF tokens¶
Added¶
pf-utils.jsrefreshes a page's CSRF tokens once they are ten minutes old, when the tab comes back into view and before a form carrying one is sent.- It updates every
_csrf_tokenfield,<meta name="csrf">, and every form-token field. - A form signed in on another tab, or one left open past its session, is no longer refused with what was typed lost.
- There is no timer, so an idle session still expires.
- It updates every
GET /Csrf(Pramnos\Application\Controllers\Csrf) answers the current tokens. It is served by the framework and never cached, and refuses a cross-site request.<meta name="csrf-refresh">on every page names it.window.pfRefreshCsrf()asks at once.pramnos-testing'sEventhasstopImmediatePropagation().
Documentation¶
- The Security Guide has a new section, A page left open: the token is refreshed.
- There is a new row in the Upgrade Guide.
Dashboard cards: four widths, settings, and drawn one at a time¶
FW-130, items 1–3.
Added¶
- Four widths on a grid of four columns:
s,m,landfull(Widget::SMALL,MEDIUM,LARGE,FULL,Widget::SIZES). The grid has two columns on a tablet and one on a phone. The width button goes round the four. - Settings per card.
Widget::options()declares what may be set: a list of values, or a function that cleans a free one.setting()reads a choice, andconfigure()hands a widget its settings.Widget::cleanSettings()is the cleaning on its own.- Each person's choices are kept in their layout (
{id, size, settings}) and cleaned against the declaration. The card's gear opens a form of them.
Widget::description(), shown as the hint on each card offered.GET …/Dashboard/card?id=…(DashboardController::card()) answers one card's HTML with this person's width and settings, or 404.pf-utils.jsuses it to put an added card, or one whose settings changed, in place without a reload, and draws its charts.
dashboard/card.html.phpin the three themes. The dashboard draws every card through it.
Changed¶
- A widget's default width is
m. A layout saved withhalfreads asm, andWidget::HALFremains as an alias. - Layouts carry
settings, andLayouts::for()andclean()answer them for every card.
Documentation¶
- The Dashboard Guide covers widths, a new Settings section and the one-card endpoint.
- There is a new row in the Upgrade Guide.
A reminder asked for in a new conversation leads back to it¶
Fixed¶
- A reminder asked for in the first question of a conversation now links to it.
- Before, it was kept with no conversation, because that conversation exists only once the question is answered. Its notification therefore led to the chat page in general.
- Now the tasks the scheduling tools made during the answer are given the new conversation's
id as soon as it is stored (
Routines::linkMade()).
Dashboard cards are dragged and resized with a finger as with a mouse¶
FW-130, item 5, ported from an application's own admin theme.
Changed¶
- Pointer events, in place of HTML5 drag and drop. HTML5 drag and drop does not work with
touch and cannot resize.
- A card is dragged by its grip (
data-pf-widget-grip). It follows the pointer, with a placeholder of its size where it will land, and the window scrolls near its edges. - It is resized by its corner (
data-pf-widget-size-handle), column by column, with the width shown on it. - The arithmetic is on
window.pfDashboard.
- A card is dragged by its grip (
dashboard/card.html.phphas the grip and the corner in the three themes, with their styles in eachcomponents.css.
Documentation¶
- The Dashboard Guide describes dragging and resizing.
A page for scheduled AI tasks and shared reports¶
Phase four of scheduled AI tasks, for applications without a SPA.
Added¶
/chat/scheduled(ChatPageController::scheduled()), in the three themes.- It lists a person's tasks, with run now, pause, resume, delete and their runs.
- It lists the reports shared with them, with receive and stop.
- It shows a task's runs, a report's with Ask about it.
- It has a form to schedule any kind, with the "for whom" choice only for somebody the policy lets share.
- The conversations page links to it.
- Its POST actions:
schedule,scheduledupdate,scheduleddelete,scheduledrunandscheduledsubscribe.
Fixed¶
- A one-off scheduled decades ahead no longer fails the insert. A MySQL
TIMESTAMPends in - Such a task is now refused with a sentence, and one more than ten years ahead is treated as a typo.
run_ataccepts2026-10-16T17:00, as adatetime-localfield sends it, beside2026-10-16 17:00.
Documentation¶
- The LLM Guide has a new subsection, The scheduled page.
- There is a new row in the Upgrade Guide.
The SPA's Scheduled screen: every kind and cadence, runs, shared reports¶
Changed¶
components/llm/Scheduled.svelteschedules questions, reminders and reports, with every cadence: once, daily, weekly, monthly, and a weekday of the month. A report offers whom it is for and when readers are told.- It shows a task's runs. A report's run opens as a conversation of the reader's own
(
continueRun, thenonopen). - It lists the reports shared with this person, to receive or stop.
lib/llm/chat.jshasroutineRuns,run,continueRun,reports,subscribeandunsubscribe, and the pathsrunsandreports.GET /chat/routinesalso answerskinds, and theownersthis person may use.- The link reads "Scheduled", in place of "Scheduled reports".
Projects take the screen with project:resync --spa-components.
Deleting an account hands its tasks over and erases what the assistant kept¶
Phase four of scheduled AI tasks, completed.
Added¶
- The delete-account page asks who takes over the person's scheduled tasks, organisation by
organisation (
OAuth2/delete_account_tasks.html.phpin the three themes).- Reports the organisation receives are proposed for a successor.
- The person's own tasks go only if they tick it.
- With nobody to take them, they are deleted.
- The hand-over runs before the erase.
Routines::handoverChoices()andhandover(), andTaskPolicy::successors(), which defaults to the organisation's other active members.- The LLM Guide has an example for an application's own member-removal flow.
Fixed¶
- Erasing an account now erases what the assistant kept. Before,
AccountErasureleft all of this behind:- the person's conversations and every message in them;
- the assistant's memory of them, their queued questions and their agreements;
- their dashboard;
- their scheduled tasks, with each run's record and each reader's subscription, and their own subscriptions.
- The call log keeps the cost and loses the person:
userid, prompt, messages and reply are cleared. - The page no longer fails where the installation keeps no organisations. An organisation's name falls back to its number.
Documentation¶
- The Authentication Guide's table of what the erase deletes has the assistant's tables.
- The LLM Guide covers asking who takes over, with an example.
- There is a new row in the Upgrade Guide.
The dashboard's catalogue, fields, and figures only when read¶
FW-133, from an application now running on the framework's dashboard.
Added¶
- A catalogue to add cards from. It is a dialog in the three themes, searched, with each
card's description.
- A card on the board is marked as such.
- A card hidden is offered again without a reload.
pf-utils.jsalso accepts[data-pf-dashboard-add="id"]buttons.
- Fields for a card's settings. An option can be
['label', 'input' => text|date|number, 'clean', 'when']or['label', 'values'].- Labels are translated.
- Dates are date fields.
- A field waits for the setting it is shown for, and is not sent while hidden.
Widget::field()describes an option, andcardFor()hands the viewfields.
DashboardController::dashboardHome(). "Back to the default" returns there and says so.- The script's words come from the page, through the board's
data-pf-dashboard-say.
Changed¶
- The dashboard no longer works out figures nobody reads. The four figures handed to a view
of the older shape are
Dashboard\Widgets\LazyFigures, worked out on first read, so the health checks no longer run on every load. $this->catalogueholds every card this person may have, each withplaced, not only the ones they may add.- A select's placed options are hidden rather than removed.
A partial falls back to the bundled scaffold, as a template does¶
Fixed¶
$this->insert()now finds a partial the application never copied. It looks in the bundled scaffold's views, last, asgetView()and a whole template already did. An application that overrodeOAuth2/delete_account.html.phpand inserteddelete_account_tasksgot nothing, and a log line, "Template partial not found".
Documentation¶
- The Framework Guide's order of where a partial is looked for has the scaffold, last.
- There is a new row in the Upgrade Guide.
Dashboard drag that a theme cannot break, by the title too; flash messages translated¶
FW-134, measured on a phone and a desktop.
Fixed¶
- A lifted card follows the pointer under daisyUI. daisyUI's
.card { position: relative }in the utilities layer beat ourposition: fixed, and the card was drawn off screen. The script now sets what drag depends on inline: position, layer, pointer events, and the resize outline. - A finger on the grip lifts the card. A
.btn'stouch-action: manipulationbeat ours, the touch scrolled, and the drag was cancelled. The handles gettouch-action: noneinline while customising. - iOS Safari selects no text while dragging:
webkitUserSelectis set besideuserSelect. - The placeholder goes beside the next element, not a text node.
Added¶
- A card's title row is a grip while customising (
data-pf-widget-head). The drag starts after a few pixels of movement, and never from a link, button or field in the row. - Flash messages are translated.
addMessage()andaddError()keep the language's translation of the words they are given, so the framework's controllers' English messages reach the page in its language. Words with no translation are kept as they are.
Documentation¶
- The Dashboard Guide covers the title row and why drag styles are inline.
- There is a new row in the Upgrade Guide.
Scheduled tasks for the analyst, and the right time shown on MySQL¶
FW-135, from an application adopting scheduled tasks for its root-only analyst.
Fixed¶
llm:routinesruns an analyst task with the analyst. It ran every due task with the chat assistant, so a task of theadminspace had neither the analyst's tools nor its prompt, and answered to chat's refusals. Queued runs were already right.- The next run is shown on its task's clock on MySQL.
- A moment is written as UTC, and MySQL answers it without an offset. PHP read it in its own zone, so "every Monday at 09:00" in Athens showed "next 06:00", and the model repeated it to the user.
Routines::utc()now reads such moments as UTC. When the work actually ran was already right.
- A notification's link built in a cron run with no
APP_URLstarts from the site's root, not from the page the bell is on (/admin/admin/Ai/…). - Deleting a task removes its readers and its runs' record.
RoutineReady's words are translated.
Added¶
- The scheduled page can be served elsewhere:
Chat\Web\SchedulesTasks, with hooks for scope, conversations and addresses. The view takes its addresses from$this->urls. - The analyst's scheduled page, at
/admin/Ai/scheduled, andAi/report/{run}. The analyst's report notifications link there, and its page links to them where the application lets it schedule. - An analyst that may schedule says so to its model, and counts times on
Analyst::timezone().
A card's title row keeps the pointer¶
FW-136.
Fixed¶
- A drag by a card's title row is no longer cancelled by the browser. During the few pixels
it waits, a press on the title could start a native drag of a link elsewhere on the page, seen
at a phone's width with a mouse, or start selecting the title's text. Either took the pointer,
and the card never lifted.
- While it waits,
dragstartandselectstartare now refused. - While customising, the row carries
user-select: noneinline.
- While it waits,
The analyst's scheduled page: its buttons, its floor, its words and its links¶
FW-137 and FW-138, from an application serving the analyst's scheduled page.
Fixed¶
- Pause and Resume answer on the analyst's page.
SchedulesTasks::scheduledupdate()read the person throughscope(), which only the chat page has, so onAi/scheduledit failed with a - It reads
taskScope(), as the other actions do. - The scheduled actions in
AiControllerare behind the admin floor. The trait's six actions skipped the usertype check its own actions andreport()make. The trait asks a new hook,taskDenied(), first;AiControlleranswers withrequireMinUserType($this->adminFloor()). - "Scheduled. Next: …" is translated: the time is a
%splaceholder, not part of the key. next_run_localis in figures,19/10/2026 09:00, notMon 19 Oct 2026, 09:00, which an English month name made English on every page, and in what the model repeated.- An analyst report's link from the site's cron leads into the administration. It used
AdminArea::url(), which prefixes only in an application with an admin area, so a site's cron wrote/Ai/report/N. Report links and conversation links shareAnalystAssistant::areaUrl().
Added¶
'llm' => ['chat' => ['notify_via' => ['database']]]keeps scheduled-task notifications to the bell.RoutineReady::channels()decides for bothRoutineReadyandTaskReminder.AiController::settingsLink()— the Settings link under Ask's heading; return''to hide it.
What breaks in subclasses¶
- An
Aicontroller that added a usertype check to the scheduled actions, or ascope()shim, can delete it. RoutineReady/TaskRemindersubclasses whosevia()only returned['database']can be replaced bynotify_via.
Four more dashboard cards: queue, email, AI spend, errors¶
Added¶
queue: tasks waiting now, and those that failed or finished in the last 24 hours. A failure is critical. On a new dashboard where thequeuefeature is on.email: email in the last 7 days from the send history (mails): sent, failed with the failure rate, and queued. The share opened appears only where tracked mail was sent in the week, never as "0%" for mail nobody tracked.ai.spend: model calls in the last 30 days, with cost, calls and failures. Offered only where thellmfeature is on and a provider's key is set.logs.errors: errors in the logs over 24 hours, and the commonest five. It reads the log files, so it is in the catalogue but not on a new dashboard.- Each is offered only to whoever may open the screen it summarises, at the framework screens'
floor (
Figures::opens()), not at the dashboard's. queueandai.spendread the analyst's own tools, so the card and the analyst give the same numbers. A card whose table is not there draws nothing.
Documentation¶
- The Dashboard Guide lists the eight framework cards, who gets each, and where each reads its figures.
push:setup finds the worker it is told about, and checks the icon and the routing¶
FW-141, from an application whose worker is www/serviceWorker.js.
Fixed¶
- The worker is found where it is.
push:setuplooked only forsw.jsandservice-worker.js, so a worker calledserviceWorker.js, with all three handlers, was reported as having none, and--applywould have written a second worker that nothing registers.'push' => ['service_worker' => …]in app.php names it, and is looked at first.serviceWorker.jsis among the names looked for.- Each step names the file it checked.
- A worker written where there was none has its placeholders filled,
{{ APP_SLUG }}among them, asinitfills them (Init::serviceWorkerValues()). The run says nothing registers it yet. - A tapped notification takes an open tab where it leads. The worker focused the tab and
posted
notification-click, and nothing listened. The browser script now navigates, within the site's own origin. A single-page application can cancelpf:notification-clickand route for itself. - The browser script works in a folder install.
push/keyandpush/subscribeare worked out from where the script is loaded, or fromdata-push-base, not from the host's root. The worker's default icon and badge are resolved against its scope.
Added¶
- A routing step. It GETs
push/keyunder the site address and expects the push controller's answer: 200 or 503, as JSON. A 301 from an application's own routing used to leave every step green and nothing subscribed. - An icon step, and
'push' => ['icon' => …, 'badge' => …], which every notification carries unless it gives its own (PushChannel::iconSetting()).
Every enqueued script and stylesheet is on the page once¶
FW-143.
Fixed¶
- A theme that calls
renderCss()andrenderJs(), as the scaffolded ones do, printed each asset twice.processHeader()put the stylesheets and head scripts into the head the document draws, and the theme printed them again.render()also appended the document's foot after the theme's foot, whichrenderJs()had already filled. jQuery, select2, jQuery UI, DataTables and pf-utils loaded twice on every screen that enqueued them.- A stylesheet now comes out where
renderCss()is. - A head script comes out in the document's
<head>, before the content, where an inline script in a screen can use it. - A footer or inline script comes out where
renderJs()is. - A layout drawn without the document's head still gets the head scripts from
renderJs().
- A stylesheet now comes out where
Hooks for the scheduled page, read-only AI cards, a push switch, and dashboard cards chosen by id¶
From FW-139 (items 2, 3, 6 and 7) and FW-140 (items 1 and 7). Each makes one of a consuming application's workarounds removable.
Added¶
SchedulesTasks::scheduledExtras(?array $task): the controller's own markup.formis printed inside the schedule form before its button, andtaskon the open task's card, in every theme. Before this, an application inserted its fields by searching the page's HTML.SchedulesTasks::afterScheduled(array $task, array $input): called with the task just made and the whole form, before the redirect. Before this, the only way to find what was made was the person's newest task.Llm\Admin\Card: a card on/admin/Aithat only shows something. It is drawn with no form and no Save button, and a post naming it is refused.Admin\Sectionnow extends it.'push' => ['send' => false], or a callable that is asked each time, switches push off for an installation.PushChannel::sends()decides. What is not sent is logged asLog::SWITCHED_OFF, andRoutineReady::channels()leaves push out.'dashboard' => ['framework' => [ids]]takes only those of the framework's cards, in that order.'dashboard' => ['abilities' => [...], 'links' => [...]]renames the ability a framework card asks for and moves or removes its link (Figures::ability(),Figures::link()).
A shared report's maker chooses its readers¶
FW-139, item 1. Before this, a Root's report reached only Root: the default canSee() decided by
the maker's usertype alone.
Added¶
ai_routines.audience, added by migration2026_10_16_000016_add_audience_to_ai_routines: everyone from a role up, people by name, or both. The defaultcanSee()reads it in place of "the maker's standing and up", still only inside the organisation for an organisation's report. Null, what every existing row has, keeps the old rule.Routines::create()andupdate()takeaudience_roleandaudience_users(usernames or emails), oraudienceas{min_usertype, users}. They refuse a role or a person that does not exist, and readers for one person's task.TaskPolicy::audienceUsers()andaudienceNames()find named readers by username or email and show them back.- The scheduled page offers the readers when a report is shared. The open report's card lets
its maker change them through
scheduledupdatewithaudience.
Push subscriptions need this site's token; push.js speaks the page's language and survives a replaced URL; admin pages' assets are not cached¶
FW-144, and item 6 of FW-141.
Fixed¶
push/subscribeandpush/unsubscriberefuse a request from another site. Only the session cookie protected them, andSameSite=Laxsends it on a POST from another subdomain of the same site, so a page there could attach its own endpoint to the signed-in account. They now need the session's token (X-CSRF-Token) or the browser'sSec-Fetch-Site: same-origin; the latter is what the worker's re-subscription has.push.jssends the token from<meta name="csrf">ordata-push-token.push.jsno longer throws "URL is not a constructor" on a page that declaresvar URL = "…". It resolves addresses with an anchor when the constructor has been replaced.- The worker no longer caches what an administration page loads. It used to skip admin and
API requests by the request's own URL, so root assets that admin pages load, such as
pf-utils.jsand fonts, were cached stale-while-revalidate. After a deploy an administrator got last release's script beside this release's HTML. The page that asked now decides, by the request's referrer or byclients.get(clientId). A worker that activates deletes its own asset cache.
Added¶
push.jssays its sentences in the page's language, fromdata-push-words, whichPush\BrowserScript::attributes()writes on its tag. The themes print it.push:setupreports apush.jswithoutdata-push-wordsor the token as out of date.
Navigation preload, a worker address that versions itself, mtime asset versions, and a stored miss with its Cache-Control¶
FW-145, items 1, 2 and 7.
Added¶
- Navigation preload in the scaffolded worker, switched on when it activates. A navigation
answers with
event.preloadResponsewhen the browser started one, and with the network otherwise. The offline page still stands in when both fail. A navigation into a skipped area uses the preload too, so the browser does not request it twice. Without preload, the worker's boot added about 50 ms to every navigation on a desktop and 250 ms or more on a phone. ServiceWorker::url($file, $params): the worker's address with?v=its modification time, then its parameters.initwrites it into the themes' registration.- A Worker caching step in
push:setup. It asks for the worker and says when it is served withoutCache-Control: no-cacheor with amax-ageover five minutes. 'mtime'as a version inregisterScript(),enqueueScript()andregisterStyle():?v=the file's modification time, asassetUrl()stamps it. Another site's address is left alone.
Fixed¶
- PageCache's
cacheControlreaches the miss that filled the cache, not only the hits after it (PageCache::storedResponse(), from the middleware). It is not applied when PHP has a cookie queued. The bfcache value,private, no-cache, is documented.
The sign-in credential is a token per device, not the password hash¶
FW-146, a security fix.
Fixed¶
$_SESSION['auth'], theauthremember-me cookie and the login response'sauthkey no longer carry the stored password hash. The hash had these problems:- it was a bearer credential for as long as the password stayed the same;
- it was identical on every device, so one could not be signed out alone;
- it was refreshed on every request;
- it handed the hash, ready for offline cracking, to whoever read the cookie or a page that printed it.
- Each sign-in now gets its own
RememberTokenstoken: random, stored only as a hash, expiring after 30 days (auth.remember_days).- It is issued in
triggerLogin()before either path writes it, so an application's own user addon writes the token too. logout()ends it.- A password change ends it, from any path.
- It is issued in
- Signing in again from the cookies (
Auth::rememberLogin(), also through theUserDatabaseaddon) uses the token once and issues a new one.
Added¶
Auth::signOutEverywhere($userId)ends every token and flags each live session to sign out.Auth::authCheck()signs in from the cookies itself when no auth addon is registered. Before this, remember-me needed theUserDatabaseaddon.- An old password-hash cookie still signs in once and is replaced, so the upgrade signs
nobody out.
'auth' => ['legacy_remember' => false]refuses it. - A token is accepted where the stored hash was expected
(
auth($username, $auth, true, true), DatabaseAuthDriver's path 4). An application's own auth addon that signs in again from the cookie this way, and a mobile client re-authenticating with theauthit was given, keep working. Without this they would be signed out once their session ended. - Before
migrate, a sign-in keeps the credential it had, and the log says to migrate.RememberTokens::issue()answers null when it cannot store the token. It reads the insert's answer, since PostgreSQL reported the missing table as false rather than throwing. - Migration
2026_10_16_000001_create_remember_tokens_table.
Documentation¶
- The Authentication Guide says never to print
$_SESSION['auth']into a page.
Readers' roles from the application, a hook when they change, translated refusals and card labels, cards tuned from app.php¶
Five gaps found while adopting FW-139 and FW-140.
Fixed¶
SchedulesTasksreadsstatic::audienceRoles(). It calledself::, so an application's override was never read, and its role list showed two roles at the same rank.- "There is no role called …" and "There is nobody called …" are translatable. The name is a
%splaceholder, not part of the sentence. - The framework's dashboard cards say their tile labels through
t()(Figures::words()).
Added¶
SchedulesTasks::afterAudienceChanged($task, $before), called when a report's readers are changed from its card.'dashboard' => ['cards' => [id => ['default' => bool, 'min_role' => role]]]for any card: whether a new dashboard has it, and a role from which it is offered, on top of its own rule (WidgetRegistry::byDefault()).
Core Web Vitals from real visitors¶
FW-145, item 9.
Added¶
pf-vitals.jsmeasures a sampled share of page views with theweb-vitalslibrary, which is now in the asset catalog (project:install web-vitals). It sends them withsendBeacononce the page is hidden.- The metrics are TTFB, FCP, LCP, INP and CLS.
- Each comes with what it depends on: page group, device, navigation type (bfcache restores
and prerenders included), delivery type, the service worker's state and start time, and its
Server-Timing.
ClientVitalsreceives them at/ClientVitalswith the checksClientErrorsapplies, and needs no controller of the application's own. The rows go toclient_vitals(migration2026_10_16_000002). The server decides signed-in or not, from the session.Logs\ClientVitals:tag()for the theme,record(),summary()(the 75th percentile per page group and device),prune(). It is configured by'vitals' => ['sample', 'groups', 'keep_days']and is off until a sample is set.- The dashboard's
vitalscard, which is not on a new dashboard. - The scaffolded footers print
ClientVitals::tag(), which prints nothing while the sample is 0.
Shared reports by email, and a task's model calls marked as its own¶
FW-139, items 4 and 5.
Added¶
- A report by email.
ai_task_subscriptions.emailused to be stored and never read. A reader whose subscription asks for it now gets the report itself throughRoutineReady::toMail(): title, Markdown words, the link to ask about it. The scheduled page offers "Receive it by email too". The notification class gets the report's words and the choice as two more constructor arguments, which an application's own class may ignore. llm_calls.routine_id, added by migration2026_10_16_000017. It marks every call a scheduled run makes (Conversations::forTask()), and the analyst'sllm_spendshows the tasks' runs apart, by task. The purpose is unchanged, because it routes the call.
One broken browser can no longer fill the PHP pool with error reports¶
FW-147. In production, one browser with a broken CacheStorage sent about 840 reports to
/ClientErrors in two minutes, in bursts of about 100 per navigation. Each report waited up to
27 s on the session lock, the PHP-FPM pool filled, and other visitors' pages took over 5 s.
Fixed¶
- The worker reports each message once in its life, at most five a minute, without cookies. It used to send one report per error, with the session cookie.
- After a failed
caches.open()the worker is network-only. It used to retry on every request, fail the asset, and report it. Storing is best effort, and the offline page's lookup cannot throw. ClientErrors(andClientVitals) let go of the session first, so the reports of one session no longer queue on its lock.- The 30-a-minute limit holds without APCu. It is counted in the application's cache
(
TokenBucket, Redis where it is one). Without APCu it used to hold nowhere.
A dashboard card's period, set by each person¶
FW-140, item 4.
Added¶
- The
Dashboard\HasPeriodtrait. It offers today, the last 7, 30 or 90 days, or two days of one's own. The choice is kept and cleaned with the layout.periodOptions()gives the settings, with the card's default first.period(),window()(as the analyst's tools take it),periodRange()andperiodLabel()read the choice back.maxDays()caps a card whose source answers for fewer days.- It is a trait rather than methods on
Widget, so an application's ownperiod()orwindow()keeps working.
- The framework's
queue,emailandai.spendcards use it. Their defaults are today, 7 and 30 days, as they were fixed before. Their titles lose the fixed period ("Email", "AI spend"), and the period is shown on the tiles.
Every message channel on one card¶
FW-140, items 2 and 3.
Added¶
Email\SendHistory: the send history's figures for a period, by status (an application's own status by number), by module, and the failures by module, with the failure rate. Theemailcard, the newmessagescard and the analyst'semailtool (its newhistorykey) all read it, so they say the same.Dashboard\MessageChannel, andMessageReachfor the people a period reached. Channels are named in'dashboard' => ['channels' => [...]]or added withWidgetRegistry::channel(). Email comes first wherever there is a send history.- The
messagescard: a row per channel for the period each person sets, with sent, failed, queued, delivered, cost and reached. It is full width and not on a new dashboard. A channel that fails is a line on the card. - The
emailcard's Failed tile names the module that failed most.
What's new, from the help, on the dashboard¶
FW-140, item 6.
Added¶
- The
help.newscard lists the administration help's pages of the news sections, newest first by theirupdatedfront matter.- The sections are
NewsandChangelogunless'dashboard' => ['help_news' => ['sections' => [...], 'limit' => 5]]says otherwise. - Each page is a link, and the whole of the help is one link away.
- It is offered where the
helpfeature is on, to whoever may open the help, and is not on a new dashboard.
- The sections are
Browser errors of extensions and other hosts' scripts are not kept¶
FW-148. On one installation, about 15,000 of 187,000 sightings in 30 days were somebody else's code: "Script error.", browser extensions, and third-party scripts (cookie banner, chat, maps, analytics).
Fixed¶
pf-utils.jssends only this site's own errors. It drops an extension's (*-extension://in the file or the stack) and a script of another host's, unless the meta'sdata-hostsnames that host. It reads hosts with a regular expression, notURL, which a page may have replaced.ClientErrorsapplies the same filter for older pages and for the{error, agent}shape, using'client_errors' => ['hosts' => [...]]from app.php. A filtered report gets204and is not kept (ClientErrors::foreign()).
A sign-in token is spent where the hash was expected, and "log in as" ends the token it replaces¶
Two findings from adopting FW-146.
Fixed¶
auth($username, $token, true, true)spends the token it was given. That is the call an application's own remember sign-in makes, and the call a client makes to re-authenticate. The token was accepted and a new one issued, but the old one stayed valid until it expired, so every remembered visit left one live token behind. The replacement is in$_SESSION['auth']and the response.- A sign-in in a session that had one ends the token it replaces. An administrator's "log in as" used to overwrite their token in the session without revoking it.
Documentation¶
- The Upgrade Guide marks
llm_calls.routine_idas required before scheduled tasks run on the new code.
Who is online now, and signing one session out¶
FW-140, item 5.
Added¶
- The
sessions.onlinecard lists the signed-in sessions of the last fifteen minutes, newest first: the account (linked), IP, time, page and browser. It shows how many are signed in and how many are visitors.- It is for whoever may open the users, and is not on a new dashboard.
- It is not final: an application adds a column by answering
rowExtras(array $session).
Users/endsession(POST, with the session's token) ends one session. The session is flagged, signs out on its next request, and its sign-in token ends with it. This administrator's own session is not ended, and the return address must be this site's.
The page cache: cookies PHP queued, the remember-me cookie, and a CDN header¶
FW-145, items 10 and 11.
Fixed¶
- A page that queued a cookie with
setcookie()is not stored.store()used to see only the Response's own headers, and the queue that PHP'ssetcookie()writes to slipped past it. The framework's visitor tracking, written on every request, is harmless (harmlessCookies), and an application adds its own. The session cookie is not harmless. - The remember-me
loggedcookie bypasses the cache. The framework keeps its cookies as an array under a prefix by an encoded name, so the default#^(auth|remember|logged)#pattern never matched them. A remembered visitor without a live session was served the anonymous page. Array-style cookies are now matched asname[key], and the framework's by the name they were given.
Added¶
cdnCacheControl: on a HIT only,CDN-Cache-Control(or a header named,Cloudflare-CDN-Cache-Control) with what is left of the entry's TTL. Off by default.
Signing in as somebody else, and back without a password¶
FW-149. Since "log in as" ends the administrator's token, getting back meant signing in again, and one installation's staff do this about 250 times a month.
Added¶
Auth::impersonate($userId)andAuth::endImpersonation()(Auth\Impersonation).- The administrator's token ends on the way in, so nothing that signs them in stays in the browser.
- The way back is a one-time key held only in the impersonated session. Its hash is kept in
impersonations(migration2026_10_16_000003) and expires after eight hours. - Coming back ends the impersonated token and gives the administrator a fresh one, with no password.
- Signing out while impersonating closes the way back.
- Both events go to the activity log.
- Who may: from
superup, only as an account of a lower usertype ('auth' => ['impersonation' => ['min_role', 'hours']]). POST Users/loginas/{id}from the administration, andPOST /Impersonationto come back, in every application.Impersonation::banner($action)shows the account being viewed, with the way back.
The worker's immutable addresses from its registration, and the page cache's Pramnos-Page signal¶
FW-145, items 3 and 4.
Added¶
register('sw.js?immutable=assets/fonts/,?v=')adds to what the scaffolded worker serves from the cache without revalidating. An entry is a path under the scope, or?namefor an address carrying that parameter.- PageCache
pageSignal(off by default):- it sends
Pramnos-Page: public; max-age=<ttl>on a HIT and on the miss just stored; - it sends
Pramnos-Page: privateon a page bypassed at runtime or carrying a private marker (PageCache::refusedResponse(), from the middleware); - it sends nothing otherwise.
- it sends
The signal is the allow-list a worker that keeps pages needs.
A login marker cookie¶
FW-145, item 6.
Added¶
'session' => ['login_marker' => 'logged_in'](Auth\LoginMarker). It is a cookie that says only "somebody is signed in", value1, not HttpOnly, for a CDN, a service worker or the page cache's early path.- It is set on every sign-in, after the user addon or the built-in path. A remembered sign-in keeps it as long as the remember-me cookies.
- It is cleared on sign-out, and by the session tracking when it outlived the session.
- It is off until named.
Speculation rules¶
FW-145, item 8.
Added¶
'speculation' => ['prefetch' => [...], 'prerender' => [...]]: the HTML document writes<script type="speculationrules">in the head.- Each rule takes
include,excludeandeagerness(moderateby default). - Every rule leaves out a sign-out, a
?format=export, arel=nofollowlink,.no-prefetchand the page cache's globbypassPaths. - The configured rules are hashed in the CSP. They need no nonce, so the page stays cacheable.
- Each rule takes
Document::addSpeculationRule($action, $rule)for a page's own rule, which carries the nonce.
Public pages in the service worker¶
FW-145, item 5.
Added¶
- The scaffolded worker keeps pages where the registration asks. The rules come in
pages=(JSON:match,strategynetwork-firstorswr,timeout,maxAge), withmaxPagesandmarker, and go to their own cache. It is off by default. - What is kept is an allow-list the server decides. Only a GET 200 marked
Pramnos-Page: public(the page cache'spageSignal) is kept, and only while the login marker is absent, read withcookieStore. A browser withoutcookieStorekeeps no pages. - The kept pages are dropped on any same-origin non-GET, a navigation answered
Pramnos-Page: private, aPramnos-SW: purge-pagesheader, or{command: 'purge-pages'}. swrtells pages when a revalidation found a new ETag, onBroadcastChannel('pramnos-sw').- Every navigation answer carries
Server-Timing: sw;desc=cache|network|preload.
A calendar: FullCalendar in the catalog, and pf-calendar.js¶
FW-142.
Added¶
fullcalendarin the asset catalog: FullCalendar 6.1.15, the MIT standard bundle, with its locales (project:install fullcalendar). It needs no jQuery.pf-calendar.jsturns<div data-pf-calendar>into a calendar.- It offers month, week and day views, from a JSON feed (
data-events). - A filter form (
data-filters) is sent with every request and refetches on change. - Clicks, drags and resizes are told to the page as
pf:calendar-*events. - With
data-update, a change is POSTed with the CSRF token and put back when refused.
- It offers month, week and day views, from a JSON feed (
- The themes'
components.cssgives the calendar the theme's colours, light and dark, and a toolbar that wraps on a phone.
Documentation¶
- A new Calendar Guide.
Signing a session out by its sid, and a page whose controller rewrites the query¶
FW-150.
Fixed¶
- The
sessions.onlinecard's Sign out carries the session'ssid, andUsers/endsessionends the row with thatsid. It used to carryvisitorid, which an application may keep as raw bytes (binary(8)). Those reached the form as an empty value, so the button signed nobody out. - The page cache keys a page from the query the request arrived with.
lookup()keeps$_GETfor its request, andstore()and the bypass rules read that copy. A controller that rewrote$_GETwhile rendering stored its page under a key no lookup computed, so the page was stored on every request and served on none, and the stale-window lock was released under the wrong key.
Documentation¶
- The Page Cache Guide says which query the key is made from.
- The Authentication Guide warns that an application's own "sign the administrator back in" shortcut undoes an impersonation.
Cropping and resizing a picture before it is uploaded, on the profile picture card first¶
Added¶
cropperjsin the asset catalog: Cropper.js 1.6.2, MIT (project:install cropperjs).pf-image.jsworks on any<input type="file" data-pf-image>. Choosing a picture opens a crop dialog: a crop box held todata-aspectwhen given, and buttons to rotate, flip, zoom and reset.- The result is sized to
data-max-width/-height(never enlarged) or exactlydata-width/-height, as JPEG, PNG or WebP. - It replaces the chosen file in the input, so the form sends it under the same name.
- Cancel or Escape empties the input.
- The page is told with
pf:image-readyandpf:image-cancel. - Without Cropper.js, or with
data-crop="0", the picture is only resized.
- The result is sized to
Html\ImageUpload:attributes([...])writes the input's attributes and refuses an unknown option.assets()prints Cropper.js only where it is installed, thenpf-image.js.
- The profile picture card crops to a square with a round guide, shows the result, and sends
it at once. Its size is the
profile_photo_upload_sizesetting: 512 by default, kept within 256–1024.ProfilePhoto::uploadSize()anduploadAttributes()give it to a theme of your own. - The themes'
components.cssstyles the dialog in the theme's colours.
Documentation¶
- A new Image Upload Guide.
- The Account Guide's profile picture section describes the crop.
project:install registers in code, and TestClient keeps the application's registrations¶
Fixed¶
project:installwrote the registration into a docblock. The scaffoldedregisterVendorLibraries()has$doc = …getDocument();as an example in its docblock, and the first match was that example. The command reported "register chartjs" and registered nothing. Lines now go into the method's body, after its last registration, and comments are skipped when looking for the place and when detecting what is registered.TestClientdropped the application's registered scripts and styles with the document it resets before each request.Html\Chartthen foundchartjsunregistered, and a test could not see a page load its library. The registrations are carried onto the fresh document, with none of them loaded.
Documentation¶
- The guides, this post and the scripts' comments named a
libraries:synccommand that does not exist. The command isproject:install(project:install cropperjs). - The Console Guide says where a registration goes. The Testing Guide says what a request's fresh document keeps.
The activity log writes wherever its table is, oauth:keys, and a registered client never half-stored¶
FW-151, the first part.
Fixed¶
ActivityLog::record()wrote nothing when theauthfeature was off, even withauthserver.user_activity_login place. An impersonation was among the events lost. The table now decides. An insert that PostgreSQL refuses withfalseis logged like an exception.- Dynamic client registration wrote
apisecret = NULL, which an older schema'sNOT NULLcolumn refuses. It writes an empty secret, which every reader takes as none.- The insert's answer is checked: a refused insert is
500 server_error, not a201for a client that does not exist. - The callbacks are read back: a
callbackcolumn too narrow for the list removes the row and answers400 invalid_redirect_uri.
- The insert's answer is checked: a refused insert is
Added¶
oauth:keysgenerates the RSA pair tokens are signed with, inapp/keys/. The private key is0600and the directory0700. An existing pair is replaced only with--force, because a new key invalidates every token signed with the old one.
Documentation¶
- The Security Guide explains how an application with lookups of its own on
usertokens.tokenmoves totoken_lookup:APP_KEYdecides, not the migration, and gives the order. It also says what adding the column yourself leaves behind. - The Authentication, AuthServer Integration, Health and Console guides describe the above.
An audit trail you can write and read, and the crop dialog in a real browser¶
FW-151, the audit log. The crop dialog fixes are from its first use in a browser.
Added¶
Auth\AuditLogwritesauthserver.audit_logon both backends. Before this, nothing in PHP did, and the only writer was a PL/pgSQL function.record($event, $actor, $target, $context)writes an event about an account.change($event, $actor, $objectType, $objectId, $old, $new)writes a record's change.query($filters, $limit)reads for an admin screen: by event or prefix, actor, target, object, a time window, andbeforeto page.- Secret-named keys are redacted at any depth (
AuditLog::redact()). A failure never reaches the caller.
audit:pruneis on the framework schedule daily. It deletes nothing until a retention is set, withaudit.retention_daysor--days.project:resync --cssbrings the theme'scomponents.cssup to date, and so does a plainproject:resync.- A copy the project changed is kept unless
--overwrite. initnow records the copy it writes.
- A copy the project changed is kept unless
Fixed¶
ActivityLog::record()redacts its details with the same rule.- The crop dialog was not centred under Tailwind, whose preflight zeroes the margin the
browser centres a modal
<dialog>with. Each theme's.pf-image-dialogsetsmargin: auto. - The crop area was empty grey under the framework's CSP. The chosen file is shown through a
blob:address, whichimg-srcdid not allow.img-srcnow carriesblob:.
Documentation¶
- The Authentication Guide has a section on the audit trail. The Image Upload Guide covers the
security policy and the styles. The Console Guide covers
audit:pruneand--css, and the Framework Guide the newimg-srcdefault.
Which OAuth scopes a person may grant, by role¶
FW-151, the scope policy.
Added¶
'oauth' => ['scopes' => [...]]gives a scope, or a glob of scopes, a floor:min_role(a role fromroles) ormin_usertype. An exact name wins over a glob.- It is checked at
/oauth/authorizeonce the person is signed in, before any consent: an error page names the scopes. - It is checked at every token issued for a person, a refresh included, so a role taken away
is felt at the next token (
invalid_scope). - A scope with no rule is open as before. A rule that cannot be read refuses.
- It is checked at
Auth\OAuth2\ScopePolicyis the rule, and the hook. An application extends it, overridesallows()and names it inoauth.scope_policy, for what a role cannot say.
Documentation¶
- The AuthServer Integration Guide has a section "Which scopes a person may grant".
The crop dialog zooms with a slider¶
Changed¶
pf-image.jszooms with a slider in place of the Zoom in and Zoom out buttons. It runs from the picture fitted (1×) to four times that. Zooming with the wheel or a pinch moves it, and stops where it stops, and Reset sets it back to 1×.- The slider is labelled with
Zoom, translated throught()like the dialog's other words. The wordsZoom inandZoom outare gone. - Each theme's
components.cssgives it the theme's primary colour (.pf-image-zoom).
Documentation¶
- The Image Upload Guide describes the slider.
The audit log's moments on MariaDB, objects beside targets, exclusions and a retention per event¶
FW-152.
Fixed¶
AuditLogandActivityLogwrote their moments withdate('c'). MariaDB drops the offset (warning 1292), and strict mode refuses the row. Both now write, compare and prune throughDatabase\Instant, a Unix time the database converts in its session's zone, so the moment is right on MySQL, MariaDB and PostgreSQL whatever that zone is. Rows written before keep what they were stored as.
Added¶
Database\Instant:at($db, $unix)to write or compare atimestampTz, andunix($db, $column, $alias)to read one back as a Unix time.AuditLog::query()rows carryat, the moment as a Unix time.AuditLog::record()takes$objectTypeand$objectIdbeside the target account, andchange()takes the account the record belongs to.AuditLog::query()takesnot_event: a list of events or prefixes left out, such as page views.- A retention per event:
'audit' => ['retention_days' => ['admin.page' => 180, 'role.*' => 3650, '*' => 730]].AuditLog::prune()takes the same map.
Documentation¶
- The Authentication Guide's audit section and the Console Guide describe the above. The Query
Builder Guide has a section on
Instant.
No offset strings into MariaDB: the language-model tables, the push log and the activity log¶
Fixed¶
CallLog::instant()andPush\Log's moments carried an offset (gmdate('…P')), which MariaDB drops with warning 1292 and strict mode refuses. Every language-model table (calls, conversations, asks, memory, routines and their runs, consents) and the push log are written through it. It is nowDatabase\Instant::utc(): the offset on PostgreSQL, the bare UTC wall clock on MySQL and MariaDB.- What MariaDB stored is unchanged; the warning and the refusal are gone.
- On MySQL with a session zone other than UTC, a value read back is now UTC, as the readers take it.
- The activity log returns to PHP's local time, as its readers parse it with
strtotime(), throughInstant::local(). The earlier entry today moved it toFROM_UNIXTIME(), which a session in a zone other than PHP's answers in its own zone. The audit log keepsInstant::at(), because it also reads throughInstant::unix(). SignInRiskcounted failed sign-ins over a window shifted by the zone's offset. It compared the activity log's local times with a UTC string; it now usesInstant::local().
Added¶
Database\Instant::utc()andlocal(): a moment as the string a column's readers expect.
Documentation¶
- The Query Builder Guide's
Instantsection lists the conventions. The LLM and Push guides say what is written.