Skip to content

16 October 2026

Forty-nine entries today:

  • The DevPanel's policy callback decides the development route only
  • schedule_task requires 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:setup finds 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, mtime asset 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:install registers 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 DevPanelController subclass replaces the development route's usertype floor, and nothing else. An account app.php admits on any deployment (the root role, 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_task without kind. The task was made as ask, and two minutes later a model was asked the words instead of the person being reminded of them.
    • kind is 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.js refreshes 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_token field, <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.
  • 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's Event has stopImmediatePropagation().

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, l and full (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, and configure() 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.js uses it to put an added card, or one whose settings changed, in place without a reload, and draws its charts.
  • dashboard/card.html.php in the three themes. The dashboard draws every card through it.

Changed

  • A widget's default width is m. A layout saved with half reads as m, and Widget::HALF remains as an alias.
  • Layouts carry settings, and Layouts::for() and clean() 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.
  • dashboard/card.html.php has the grip and the corner in the three themes, with their styles in each components.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, scheduledrun and scheduledsubscribe.

Fixed

  • A one-off scheduled decades ahead no longer fails the insert. A MySQL TIMESTAMP ends in
  • Such a task is now refused with a sentence, and one more than ten years ahead is treated as a typo.
  • run_at accepts 2026-10-16T17:00, as a datetime-local field sends it, beside 2026-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.svelte schedules 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, then onopen).
  • It lists the reports shared with this person, to receive or stop.
  • lib/llm/chat.js has routineRuns, run, continueRun, reports, subscribe and unsubscribe, and the paths runs and reports.
  • GET /chat/routines also answers kinds, and the owners this 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.php in 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() and handover(), and TaskPolicy::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, AccountErasure left 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.js also 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, and cardFor() hands the view fields.
  • 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->catalogue holds every card this person may have, each with placed, 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, as getView() and a whole template already did. An application that overrode OAuth2/delete_account.html.php and inserted delete_account_tasks got 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 our position: 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's touch-action: manipulation beat ours, the touch scrolled, and the drag was cancelled. The handles get touch-action: none inline while customising.
  • iOS Safari selects no text while dragging: webkitUserSelect is set beside userSelect.
  • 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() and addError() 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:routines runs an analyst task with the analyst. It ran every due task with the chat assistant, so a task of the admin space 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_URL starts 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, and Ai/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, dragstart and selectstart are now refused.
    • While customising, the row carries user-select: none inline.

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 through scope(), which only the chat page has, so on Ai/scheduled it failed with a
  • It reads taskScope(), as the other actions do.
  • The scheduled actions in AiController are behind the admin floor. The trait's six actions skipped the usertype check its own actions and report() make. The trait asks a new hook, taskDenied(), first; AiController answers with requireMinUserType($this->adminFloor()).
  • "Scheduled. Next: …" is translated: the time is a %s placeholder, not part of the key.
  • next_run_local is in figures, 19/10/2026 09:00, not Mon 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 share AnalystAssistant::areaUrl().

Added

  • 'llm' => ['chat' => ['notify_via' => ['database']]] keeps scheduled-task notifications to the bell. RoutineReady::channels() decides for both RoutineReady and TaskReminder.
  • AiController::settingsLink() — the Settings link under Ask's heading; return '' to hide it.

What breaks in subclasses

  • An Ai controller that added a usertype check to the scheduled actions, or a scope() shim, can delete it.
  • RoutineReady/TaskReminder subclasses whose via() only returned ['database'] can be replaced by notify_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 the queue feature 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 the llm feature 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.
  • queue and ai.spend read 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:setup looked only for sw.js and service-worker.js, so a worker called serviceWorker.js, with all three handlers, was reported as having none, and --apply would have written a second worker that nothing registers.
    • 'push' => ['service_worker' => …] in app.php names it, and is looked at first.
    • serviceWorker.js is 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, as init fills 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 cancel pf:notification-click and route for itself.
  • The browser script works in a folder install. push/key and push/subscribe are worked out from where the script is loaded, or from data-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/key under 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() and renderJs(), 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, which renderJs() 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().

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. form is printed inside the schedule form before its button, and task on 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/Ai that only shows something. It is drawn with no form and no Save button, and a post naming it is refused. Admin\Section now 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 as Log::SWITCHED_OFF, and RoutineReady::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 migration 2026_10_16_000016_add_audience_to_ai_routines: everyone from a role up, people by name, or both. The default canSee() 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() and update() take audience_role and audience_users (usernames or emails), or audience as {min_usertype, users}. They refuse a role or a person that does not exist, and readers for one person's task.
  • TaskPolicy::audienceUsers() and audienceNames() 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 scheduledupdate with audience.

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/subscribe and push/unsubscribe refuse a request from another site. Only the session cookie protected them, and SameSite=Lax sends 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's Sec-Fetch-Site: same-origin; the latter is what the worker's re-subscription has. push.js sends the token from <meta name="csrf"> or data-push-token.
  • push.js no longer throws "URL is not a constructor" on a page that declares var 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.js and 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 by clients.get(clientId). A worker that activates deletes its own asset cache.

Added

  • push.js says its sentences in the page's language, from data-push-words, which Push\BrowserScript::attributes() writes on its tag. The themes print it.
  • push:setup reports a push.js without data-push-words or the token as out of date.

FW-145, items 1, 2 and 7.

Added

  • Navigation preload in the scaffolded worker, switched on when it activates. A navigation answers with event.preloadResponse when 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. init writes it into the themes' registration.
  • A Worker caching step in push:setup. It asks for the worker and says when it is served without Cache-Control: no-cache or with a max-age over five minutes.
  • 'mtime' as a version in registerScript(), enqueueScript() and registerStyle(): ?v= the file's modification time, as assetUrl() stamps it. Another site's address is left alone.

Fixed

  • PageCache's cacheControl reaches 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'], the auth remember-me cookie and the login response's auth key 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 RememberTokens token: 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.
  • Signing in again from the cookies (Auth::rememberLogin(), also through the UserDatabase addon) 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 the UserDatabase addon.
  • 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 the auth it 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

  • SchedulesTasks reads static::audienceRoles(). It called self::, 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 %s placeholder, 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.js measures a sampled share of page views with the web-vitals library, which is now in the asset catalog (project:install web-vitals). It sends them with sendBeacon once 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.
  • ClientVitals receives them at /ClientVitals with the checks ClientErrors applies, and needs no controller of the application's own. The rows go to client_vitals (migration 2026_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 vitals card, 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.email used to be stored and never read. A reader whose subscription asks for it now gets the report itself through RoutineReady::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 migration 2026_10_16_000017. It marks every call a scheduled run makes (Conversations::forTask()), and the analyst's llm_spend shows 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 (and ClientVitals) 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\HasPeriod trait. 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() and periodLabel() 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 own period() or window() keeps working.
  • The framework's queue, email and ai.spend cards 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. The email card, the new messages card and the analyst's email tool (its new history key) all read it, so they say the same.
  • Dashboard\MessageChannel, and MessageReach for the people a period reached. Channels are named in 'dashboard' => ['channels' => [...]] or added with WidgetRegistry::channel(). Email comes first wherever there is a send history.
  • The messages card: 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 email card'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.news card lists the administration help's pages of the news sections, newest first by their updated front matter.
    • The sections are News and Changelog unless '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 help feature is on, to whoever may open the help, and is not on a new dashboard.

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.js sends 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's data-hosts names that host. It reads hosts with a regular expression, not URL, which a page may have replaced.
  • ClientErrors applies the same filter for older pages and for the {error, agent} shape, using 'client_errors' => ['hosts' => [...]] from app.php. A filtered report gets 204 and 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_id as 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.online card 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.

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's setcookie() 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 logged cookie 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 as name[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) and Auth::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 (migration 2026_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 super up, only as an account of a lower usertype ('auth' => ['impersonation' => ['min_role', 'hours']]).
  • POST Users/loginas/{id} from the administration, and POST /Impersonation to 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 ?name for 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: private on a page bypassed at runtime or carrying a private marker (PageCache::refusedResponse(), from the middleware);
    • it sends nothing otherwise.

The signal is the allow-list a worker that keeps pages needs.

FW-145, item 6.

Added

  • 'session' => ['login_marker' => 'logged_in'] (Auth\LoginMarker). It is a cookie that says only "somebody is signed in", value 1, 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, exclude and eagerness (moderate by default).
    • Every rule leaves out a sign-out, a ?format= export, a rel=nofollow link, .no-prefetch and the page cache's glob bypassPaths.
    • The configured rules are hashed in the CSP. They need no nonce, so the page stays cacheable.
  • 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, strategy network-first or swr, timeout, maxAge), with maxPages and marker, 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's pageSignal) is kept, and only while the login marker is absent, read with cookieStore. A browser without cookieStore keeps no pages.
  • The kept pages are dropped on any same-origin non-GET, a navigation answered Pramnos-Page: private, a Pramnos-SW: purge-pages header, or {command: 'purge-pages'}.
  • swr tells pages when a revalidation found a new ETag, on BroadcastChannel('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

  • fullcalendar in the asset catalog: FullCalendar 6.1.15, the MIT standard bundle, with its locales (project:install fullcalendar). It needs no jQuery.
  • pf-calendar.js turns <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.
  • The themes' components.css gives 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.online card's Sign out carries the session's sid, and Users/endsession ends the row with that sid. It used to carry visitorid, 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 $_GET for its request, and store() and the bypass rules read that copy. A controller that rewrote $_GET while 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

  • cropperjs in the asset catalog: Cropper.js 1.6.2, MIT (project:install cropperjs).
  • pf-image.js works on any <input type="file" data-pf-image>. Choosing a picture opens a crop dialog: a crop box held to data-aspect when given, and buttons to rotate, flip, zoom and reset.
    • The result is sized to data-max-width/-height (never enlarged) or exactly data-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-ready and pf:image-cancel.
    • Without Cropper.js, or with data-crop="0", the picture is only resized.
  • Html\ImageUpload:
    • attributes([...]) writes the input's attributes and refuses an unknown option.
    • assets() prints Cropper.js only where it is installed, then pf-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_size setting: 512 by default, kept within 256–1024. ProfilePhoto::uploadSize() and uploadAttributes() give it to a theme of your own.
  • The themes' components.css styles 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:install wrote the registration into a docblock. The scaffolded registerVendorLibraries() 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.
  • TestClient dropped the application's registered scripts and styles with the document it resets before each request. Html\Chart then found chartjs unregistered, 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:sync command that does not exist. The command is project: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 the auth feature was off, even with authserver.user_activity_log in place. An impersonation was among the events lost. The table now decides. An insert that PostgreSQL refuses with false is logged like an exception.
  • Dynamic client registration wrote apisecret = NULL, which an older schema's NOT NULL column 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 a 201 for a client that does not exist.
    • The callbacks are read back: a callback column too narrow for the list removes the row and answers 400 invalid_redirect_uri.

Added

  • oauth:keys generates the RSA pair tokens are signed with, in app/keys/. The private key is 0600 and the directory 0700. 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.token moves to token_lookup: APP_KEY decides, 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\AuditLog writes authserver.audit_log on 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, and before to page.
    • Secret-named keys are redacted at any depth (AuditLog::redact()). A failure never reaches the caller.
  • audit:prune is on the framework schedule daily. It deletes nothing until a retention is set, with audit.retention_days or --days.
  • project:resync --css brings the theme's components.css up to date, and so does a plain project:resync.
    • A copy the project changed is kept unless --overwrite.
    • init now records the copy it writes.

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-dialog sets margin: auto.
  • The crop area was empty grey under the framework's CSP. The chosen file is shown through a blob: address, which img-src did not allow. img-src now carries blob:.

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:prune and --css, and the Framework Guide the new img-src default.

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 from roles) or min_usertype. An exact name wins over a glob.
    • It is checked at /oauth/authorize once 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.
  • Auth\OAuth2\ScopePolicy is the rule, and the hook. An application extends it, overrides allows() and names it in oauth.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.js zooms 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 through t() like the dialog's other words. The words Zoom in and Zoom out are gone.
  • Each theme's components.css gives 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

  • AuditLog and ActivityLog wrote their moments with date('c'). MariaDB drops the offset (warning 1292), and strict mode refuses the row. Both now write, compare and prune through Database\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 a timestampTz, and unix($db, $column, $alias) to read one back as a Unix time.
  • AuditLog::query() rows carry at, the moment as a Unix time.
  • AuditLog::record() takes $objectType and $objectId beside the target account, and change() takes the account the record belongs to.
  • AuditLog::query() takes not_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() and Push\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 now Database\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(), through Instant::local(). The earlier entry today moved it to FROM_UNIXTIME(), which a session in a zone other than PHP's answers in its own zone. The audit log keeps Instant::at(), because it also reads through Instant::unix().
  • SignInRisk counted 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 uses Instant::local().

Added

  • Database\Instant::utc() and local(): a moment as the string a column's readers expect.

Documentation

  • The Query Builder Guide's Instant section lists the conventions. The LLM and Push guides say what is written.