Skip to content

21 October 2026

Fifty entries today:

  • schedule:list sees the work process that is running
  • work no longer skips a minute
  • broadcast:serve holds its lock, so the orchestrator stops restarting it
  • A page signed in by an application's own login gets the realtime connection
  • queue:process writes a line per task into a file instead of its dashboard
  • A link can open its page in a dialog — data-pf-modal
  • The services screen is the state of the workers; a pool's settings have a page of their own
  • A slow command no longer holds the schedule, and every run is recorded
  • A page opened in a dialog shows its content, centred
  • data-confirm on a form asks when it is sent, not at every click inside it
  • Every tab of a site shares one realtime connection
  • ./dockertest opens the coverage report only when asked
  • A shared realtime connection that cannot start leaves each page its own, and says what it is doing
  • A page can offer its forms to an AI agent in the browser (WebMCP), and the debug bar shows them
  • The schedule has a screen: what runs, when, how it went, and what is late
  • The admin and the debug toolbar offer tools to an AI agent in the browser
  • The notification bell links to every notification, and a message's subject is its title
  • Enabling the debug toolbar returns to the page it was opened from
  • A notification arriving changes the tab's title, can play a sound, and tells the page
  • A rich-text editor: pictures with captions, uploads, a link search, and plugins
  • The rich-text editor in a Svelte SPA
  • The rich-text editor can float a toolbar over the block at the cursor
  • An editor whose textarea sits in its label is drawn after the label
  • The rich editor takes a title into its column, and its packages go beside the project's own
  • The mail log records the account each message was for
  • The tab's title flashes only for a notification that is news
  • The rich editor's buttons have words of their own
  • The query string reaches $_REQUEST behind a rewrite without QSA
  • A message's text part reads as a message
  • Message forms have saved messages, placeholders, writing with AI and a signature
  • An email's preview opens its links and does not count as an open
  • The message composer asks in its own panels, and a message to one person inserts the person's name
  • Events that run in the queue
  • work loses no minute to a restart
  • The outbox deadline sweep gets an index
  • Domain events as classes
  • Scheduled tasks an operator may adjust
  • Audiences: attributes of a person, and filters over them
  • Segments: named audiences kept up to date
  • Group membership through one path, with events
  • Consent lists: what a send asks before it reaches somebody
  • An audience builder and picker with a live count
  • The Segments and Consent lists screens, and segments on the person card
  • A mass message's audience from segments, groups, a filter and a consent list
  • What people did: person events, recorded from event classes, pages, email and the past
  • Audiences do nothing until their tables exist
  • Rules: when, who, what, until — run in the queue
  • Trying a rule before it runs
  • Messages on the site: banners, popups and cards
  • The Rules screen: the list, the editor, a rule's boxes and its tools

schedule:list sees the work process that is running

Fixed

  • "No work process is running" beside one that was, executing tasks. schedule:list built its WorkerLock from the name pramnos-work.lock, and the lock's default path adds .lock, so it read var/pramnos-work.lock.lock — a file nothing writes. It asks Work::lockFilePath(), the path the worker itself uses, supervisor's override included. Its test had taken the lock the same wrong way, which is why it passed (FW-208).

work no longer skips a minute

Fixed

  • A task due in a minute no pass looked at did not run. Each pass asked about the current minute only, ran what was due one task after another, then slept sixty seconds from when it ended. A nine-second mail flush was enough: passes at 03:19:59 and 03:21:09, nothing asked about 03:20, and a task on 5,20,35,50 did not run — a weekly one would have waited a week. A pass now asks about every minute since the last one, at most Work::CATCH_UP_MINUTES (15) back, runs each due task once, and sleeps to the next minute's start (FW-210).
  • --interval below sixty ran an every-minute task twice a minute. A minute already asked about is not asked again.

broadcast:serve holds its lock, so the orchestrator stops restarting it

Fixed

  • The orchestrator restarted the WebSocket server every cycle, dropping every open page: its entry declares var/pramnos-broadcast-serve.lock, and broadcast:serve never took a lock or wrote a heartbeat, so it stayed "started-unverified" and was gracefully stopped to be started again. The Services screen, reading the same absent file, showed the running server as stopped and offered to start a second. It takes the lock, heartbeats from the event loop every ten seconds, and releases it on exit; a second one refuses while the first holds it (FW-209). An application that set 'requireLockFile' => false on the entry to get by can remove it.

A page signed in by an application's own login gets the realtime connection

Fixed

  • <meta name="pf-realtime"> was written only for a session holding a framework token. An application whose login is its own sets uid and logged and no usertoken, so none of its signed-in pages connected and the notification bell never went live. The meta now follows Session::staticIsLogged() as well — the test getCurrentUser(), and so /broadcasting/auth, already made (FW-211).

queue:process writes a line per task into a file instead of its dashboard

Fixed

  • A worker run into a file painted its whole dashboard there, five times a second. Only a supervised worker skipped it; one started by hand with >> var/logs/ask-worker.log and --sleep=2 wrote borders, escape sequences and "Press Ctrl+C to exit" until the file was 542 MB, the task results lost inside it. The dashboard is painted only on a terminal; otherwise each task is one plain line — time, id, type, status, duration, message — and an empty pass writes nothing (FW-214).

Added

  • data-pf-modal on a link opens its page in a <dialog> over the current one: fetched with X-Pf-Fragment: 1, closed by Escape, the × or the backdrop, focus back on the link afterwards. Without <dialog>, on a modified click or a failed fetch it is an ordinary link (FW-213).
  • Document::isFragmentRequest(), and the request with that header gets the raw document, so any screen answers with its content alone — no per-screen code. The page cache keys the fragment apart from the page, and Breadcrumb::admin() draws no trail into it.
  • The queue screen's "Statistics — last 48 hours" and each task's id open in the dialog instead of leaving the list.

The services screen is the state of the workers; a pool's settings have a page of their own

Changed

  • /admin/Services shows the workers' state and nothing to configure, in reading order: the supervisor, the services (with Restart all), the worker pools — their task types, workers, backlog, load, decision, state, Stop / Start — and the batches. The pool forms that sat above the services, so that seeing whether anything was running meant scrolling past settings, are gone from it; Configure pools links to them.
  • Every label on the screen goes through t(), with the Greek in the framework's catalogue, and the ten-second refresh writes its sentences in the page's language. Restart all asks through data-confirm instead of an inline onsubmit.

Added

  • /admin/Services/pools (ServicesController::pools()), linked from the services screen and from Settings: a card per pool. Task types are boxes to tick, from those with a handler (the framework's, queue.handlers, the queue's task classes), those in the queue table and those a pool names, with a field for any other. Every limit has a label in words and a line under it saying what it does — "Always keep at least this many workers running", "Start another worker when more than this many tasks are waiting". A code pool shows its declared values as placeholders and keeps them for a blank field; nothing ticked keeps its declared types. Save, Stop / Start, and Reset to code or Remove (asked first) on each card, and a last card to add a pool.
  • poolsave accepts types[] and types_other beside the plain types string, so a published form of an application's own keeps working. Save and Remove return to the pools page; Stop and Start return to the screen they were pressed on (back=pools).

A slow command no longer holds the schedule, and every run is recorded

Added

  • ScheduledTask::runInBackground() for command tasks: started detached through the hidden schedule:background, which holds the overlap lock until the command exits and records its end — the pass does not wait. A four-minute command due every minute of an hour had held work for the whole hour on one installation; the tasks due at :15 and :20 behind it never ran, and the catch-up gave up after fifteen minutes (FW-216).
  • The run history, scheduleruns (migration 2026_10_21_000001): every run, whoever started it, with its duration, result, exit status and the end of its output; RunLog to read it, and to ask for a run that the next pass takes once. Runs older than schedule.history_days (14) are removed (FW-212).
  • CronExpression::nextRunAfter(), previousRunBefore() and describe() — a task's next run, the last one it was due, and the expression in words.

A page opened in a dialog shows its content, centred

Fixed

  • Every data-pf-modal dialog opened empty. A fragment request was given the raw document, and a controller takes its view's type from the document, so each screen looked for history.raw.php, found nothing and answered 200 with no body. The document stays html and leaves only the theme and the head out (FW-213).
  • The dialog sat in the top-left corner under Tailwind, whose preflight sets every margin to 0 and so cancels the browser's own centring. .pf-modal puts margin: auto back in every theme.

data-confirm on a form asks when it is sent, not at every click inside it

Fixed

  • A form carrying data-confirm asked at every click inside it — on a field, a checkbox, a label, the empty space — because the click handler matched the nearest [data-confirm], the form. Cancel then prevented the click, so a box could not even be ticked. A form's question is asked once, on submit, and not when the button that sent it has its own (FW-217).

Every tab of a site shares one realtime connection

Changed

  • window.pfRealtime runs over a SharedWorker where the browser has one. N tabs made N sockets and N /broadcasting/auth calls, and every page change closed one socket and opened another. pf-utils.js now runs itself as the shared worker pf-realtime, which holds one connection for every tab: tabs subscribe through it, a private channel is signed by a tab that listens on it (the next one if that tab leaves first), events go to every tab on the channel, a channel no tab wants is left, and after a reconnect each tab gets one pf:notifications:refresh. Without SharedWorker each page keeps its own connection. The API is unchanged; pfRealtime.shared says which way a page is connected (FW-215).

./dockertest opens the coverage report only when asked

Changed

  • The framework's ./dockertest --coverage no longer opens the report in a browser unless given --browser. Most runs are made by scripts and tools, and each opened a tab. --nobrowser is still accepted and is now the default.

A shared realtime connection that cannot start leaves each page its own, and says what it is doing

Fixed

  • A page whose shared worker did not start had no connection at all. The worker now answers a tab's hello at once; without that answer in five seconds, or on the worker's error, the page opens a connection of its own with the channels it asked for (FW-215).

Added

  • window.pfRealtime.status(), in every tab: the socket's state and id, the channels the server confirmed, the last error — a refused signature, a pusher:error — whether the connection is shared, and why it is not where it tried to be. The worker sends its state to every tab as it changes.

A page can offer its forms to an AI agent in the browser (WebMCP), and the debug bar shows them

Added

  • data-pf-tool on a form makes it a tool an AI agent in the visitor's browser can call, registered by pf-utils.js with WebMCP (document.modelContext.registerTool(), a W3C Community Group draft). The input schema comes from the fields and their labels; a call fills the form and sends it as the page would — current CSRF token, X-Pf-Fragment: 1 — and answers with the resulting page's text. A POST form is marked consequential.
  • window.pfTools: list(), call(), calls(), register() for a tool written in script, supported; pf:tool:call and pf:tools:change events. There with or without WebMCP.
  • The debug toolbar's Tools tab, on a page that offers tools: each with its schema and a Run that calls it as an agent would, and every call — an agent's or the panel's — with its input, result and time.

The schedule has a screen: what runs, when, how it went, and what is late

Added

  • /admin/Scheduler (System → Scheduler), the framework's own controller: every task with its expression in words, its next run, its last run — when, how long, how it ended — and whether it runs without overlap, on one server, in process or in the background. A task is marked late when the last minute its expression asked for has no run since, failed when its last run ended in an error, and running while a run is in progress. A note says when no work process holds its lock (FW-212).
  • A page per task, opened in a dialog from the list: its runs, newest first, each with its exit status, host and output.
  • Run now: a POST, audited, that asks the next pass of work or schedule:run to run the task once; nothing runs in the request.
  • A dashboard card with the late, failed and running counts, and Pramnos\Scheduling\ScheduleStatus, which the screen and the card both read.

The admin and the debug toolbar offer tools to an AI agent in the browser

Added

  • site_search wherever the admin's search box is: the omnibox's own search, by kind.
  • Admin forms as tools: search_help, list_api_tokens, list_token_actions, find_list_subscribers, and run_scheduled_task (consequential) on the scheduler screen — whose Run now also takes the task as a task field.
  • The debug toolbar's data as read-only tools: debug_requests, debug_request (one request's SQL, timings, logs, exceptions — masked) and debug_client_errors, offered through pfTools where pf-utils.js is on the page and straight to the browser where it is not.
  • A GET form tool replaces the address's query with its fields, as the browser does.

Added

  • NotificationBell::render(['all' => $url, 'allLabel' => 'See all']): a link at the foot of the panel to the page of every notification, under the list and when it is empty. None without all (FW-218).

Fixed

  • A Notification\Message reached the feed with no title. It writes its title as subject; Feed read only title. An item's title is title, else subject.

Enabling the debug toolbar returns to the page it was opened from

Fixed

  • /debugbar sent you to the site root after enabling, unless it was opened from the DevPanel's switch. The page you came from was read only at the POST, whose referrer is the /debugbar screen itself, so nothing had been remembered. The screen remembers it when it opens (FW-220).

A notification arriving changes the tab's title, can play a sound, and tells the page

Added

  • pf:notification on the document when one arrives over the realtime connection, with its id, type, title and data, read from the feed with the page's session — nothing beyond the id travels over the socket (FW-219).
  • The tab's title shows "(N) " while N are unread and, on an arrival, alternates with its title until the tab is looked at. On by default; NotificationBell::render(['titleFlash' => false]) turns it off.
  • 'sound': true for a short tone made in the page, or a sound's address. Played once per browser — the shared connection's worker names one tab, the visible one, else the last seen. Off by default.

Added

  • 'messaging' => ['editor' => 'rich'] and MessageEditor::RICH: pf-rich-editor.js, TipTap bundled into one file that ships with the other pf-*.js. Pictures as <figure><img alt><figcaption>, uploaded by drop, paste or choice to messaging.rich.upload; a link panel that searches messaging.rich.links; the selection kept while a panel has the focus; blocks moved with Alt+↑/↓; embed previews. The controls' words go through t().
  • Plugins: pfRichEditor.use({ name, extensions(tiptap), buttons, setup(ui) }) adds a site's own marks, nodes, buttons and panels with the toolkit the editor runs on, from a plain script; pfRichEditor.embeds.add({ name, match }) adds an embed provider, asked before YouTube and Vimeo. A field picks its plugins with data-pf-rich-plugins.
  • pfRichEditor.plain(textarea, on) for a text-only channel, as the mass-message form uses for push. The Rich Text Editor guide has the rest.
  • The sources are in resources/js/rich-editor/, built by npm run build:editor; a test fails when the shipped file is older than its sources.

The rich-text editor in a Svelte SPA

Added

  • project:resync --rich-editor for a Svelte SPA: the editor's core and stylesheet in lib/rich-editor/ — the same sources the server-rendered bundle is built from, refreshed by every resync after — a components/RichEditor.svelte that is the project's, and the TipTap packages in package.json, a version the project chose kept. One editor for server pages and SPA screens, so a fix lands in both.

The rich-text editor can float a toolbar over the block at the cursor

Added

  • A block toolbar, chosen when the editor is set up: over the block at the cursor while the text has the focus — move it, paragraph or heading, bold, italic, link — with a plugin's block: true buttons. Off by default; messaging.rich.block_toolbar, the field's data-pf-rich-block-toolbar, or blockToolbar in create() and the Svelte component.

An editor whose textarea sits in its label is drawn after the label

Fixed

  • Typing into a builtin editor turned on bold when its textarea was written inside its label (<label>Text <textarea></label>): the toolbar and the text were drawn inside the label, and a browser sends every click in a label to its first labelable element — the B button. Both editors, builtin and rich, are drawn after such a label; a click on its words puts the cursor in the text, and the builtin editor's text is named by the label's words alone (FW-221).

The rich editor takes a title into its column, and its packages go beside the project's own

Added

  • header in createRichEditor() — and data-pf-rich-header="#title" on a field — moves an element into the text's column, under the toolbar, as a page of an article has its title; destroy() puts it back. The Svelte component's header snippet goes there now, instead of above the toolbar.

Fixed

  • project:resync --rich-editor put @tiptap/pm in devDependencies beside a project's @tiptap/core in dependencies. The packages go where the project keeps its other TipTap ones, else in devDependencies.

The mail log records the account each message was for

Added

  • mails.userid (migration 2026_10_21_000002_add_userid_to_mails, with an index (userid, date)), written by Email::forUser($id) and by a notification's mail for its notifiable. The account's page and the history's ?user= list an account's mail whatever address it went to, so an address change no longer hides what was sent before it; the history names the account. Without the column everything matches by address, as before: a log too large to index now can exclude the migration (FW-222).
  • Pramnos\Email\MailLog: recordsUser() and ofUser(), the one place that decides how an account's mail is found.

Fixed

  • "← Back" on a message always went to the whole history. It returns to the page it was opened from — the account's page, a filtered list — when that is one of the site's.

The tab's title flashes only for a notification that is news

Fixed

  • The title flashed "(0) Security change…" with nothing new in the bell. Every notification.created flashed the title and played the sound — one written already read, for the record, too, and one read in another tab first — and an id not among the newest fell back to the first item. Both now need the notification found by its id and unread, and never a "(0) " prefix; pf:notification still goes out, with read (FW-223).

The rich editor's buttons have words of their own

Fixed

  • The bulleted-list button read "• list" — the last word of its title, which in Greek became "• κουκκίδες" — and could not be changed without changing its accessible name. Its visible words are a label of their own, bulletListShort, as is the picture-by-address button's, pictureUrlShort; both go through t().

The query string reaches $_REQUEST behind a rewrite without QSA

Fixed

  • An unsubscribe link said "This link is not valid", and one-click POST answered 400, on a site that routes through RewriteRule ^(.*)$ index.php?r=$1 without QSA: the query was only in REQUEST_URI, Request parsed it into $_GET but not into $_REQUEST, and the token is read with get('u', '', 'request'). What Request parses from the query reaches $_REQUEST too, where it has no such key — a POSTed value still wins (FW-225).

A message's text part reads as a message

Added

  • Email::setTextBody(): the text part as the application writes it — the message and a short footer — instead of the whole template converted. Sent as written, queued or not, with the unsubscribe link added when the message offers one and the text lacks it. Kept inside the stored body (TextPart), not in a column — compressed and shared as bodies are — and shown on the message's page as the text that went; a resend sends it as the text part again (FW-226).
  • data-pf-plain="omit" in a template leaves its decoration out of the text.

Fixed

  • The converted text part of a designed template was a mess — and it is what recipients get:
  • a logo or an icon link became a bare address on a line of its own; it is left out, or reads [alt] <url>;
  • a menu ran together and wrapped between a link's words and its address; a run of links is one a line, never split;
  • a layout table without role="presentation" had its cells glued with |; cells holding paragraphs or pictures mark it as layout.

Message forms have saved messages, placeholders, writing with AI and a signature

Added

  • Html\MessageComposer, a toolbar above a message form, wired by pf-utils.js through data-pf-composer with whichever editor the body is in — builtin, rich, TinyMCE or none. The framework's "Message one user" and mass-message forms carry it (FW-224).
  • Saved messages (savedmessages, migration 2026_10_21_000003_create_savedmessages_table): a list that fills the subject and the body, "Save this message" for the account or — with messages.share where the application defines it — for everybody, by scope. Placeholders stay as written.
  • Placeholders: data-pf-editor-fields draws a button per {token}, inserted at the cursor of the subject or the body. The framework's forms fill {firstname}, {lastname}, {full_name}, {username} and {email} for each recipient when they send (MessageComposer::fill(), valuesFor()); a mailing list's subscriber gets its address and the rest empty.
  • Write with AI through the configured language model: from an instruction, or the text improved; every placeholder kept, in the form's language when given; logged and costed as compose; the body cleaned.
  • A signature per account (usersettings mail.signature), edited at Composer/signature; "With my signature" adds it under the body when the form is sent, once.
  • window.PfEditor.getHTML(), setHTML() and insert(), so another script can work with the builtin editor.

Fixed

  • A link in a message's preview did nothing: the frame was sandboxed without popups. It allows them now, scripts still off, and the preview opens every link in a window of its own (FW-228).
  • Looking at a tracked message counted as an open: the preview drew the tracking pixel. It is left out of what the frame draws (MessageReport::preview()); the report still reads the body as it was kept.

Added

  • data-pf-refresh-on-return in pf-utils.js: an element with an id is fetched again, as a fragment, when the reader comes back to the page. The message page's Tracking tab carries it for a tracked message, so a click made from the preview shows on return.

The message composer asks in its own panels, and a message to one person inserts the person's name

Changed

  • The fields are one "Fields ▾" dropdown instead of a row of buttons; MessageComposer::toolbar(['fields' => 'buttons']) keeps the row (FW-229).
  • No browser dialogs.
  • "Write with AI…" opens a panel under the toolbar: what to say, Write or Improve, disabled while it writes, and Undo to put back the subject and the body it replaced.
  • "Save this message" opens a name field with "For everybody" where the account may share.
  • "Forget" asks by a second press.
  • Errors go to the toolbar's status line.
  • The saved messages' list is hidden while it is empty, and appears after the first save.
  • The toolbar's controls carry the scaffold_theme's button and field classes beside their pf-composer-* hooks, and each theme's components.css lays them out.

Added

  • A field may carry a value. The dropdown inserts the value instead of the token, so a message to one person reads "Cathy".
  • MessageComposer::fields($fields, $values) and fieldsJson($fields, $values) build the list.
  • valuesFor() also takes an account's row as an array.
  • "Message one user" passes its recipient's values; the mass-message form keeps the tokens.
  • The dropdown's label is translated under the key Recipient details (Greek «Στοιχεία»), not Fields: an application with its own Fields translation renamed the button.
  • Escape closes the dropdown.
  • Saving a message to one person kept that person's details: "Γεια σου Cathy", put in from the menu, was saved as written and greeted everyone as Cathy. A value from the menu, or from a saved message filled for this recipient, goes back to its token when saved.

Events that run in the queue

Added

  • Pramnos\Event\QueuedListener: a listener Event::fire() queues instead of calling. A worker runs it with the event's arguments, as a task of type event.listener that the framework registers itself. Its place in fire()'s results is the task id, and it never stops the listeners after it (FW-227).
  • The task is written in the caller's transaction on the application's connection: a rollback takes it with the change. A transaction on another connection does not cover it.
  • Folding by key — debounceKey(), debounceWindow() (60 s), debounceCeiling() (600 s): events with one key are one waiting task with the last event's arguments, counting the others in absorbed. QueueManager::addKeyed() does the same for any task.
  • Work done once per key — idempotencyKey(), idempotencyTtl() (30 days): a used key queues nothing, and a task that finds it taken completes as skipped. A failed run gives it back.
  • Event::fireAs(?int $userid, …): who fired it, for a cron, a webhook or a worker; null is the system.
  • Every task carries its event — id, time, who, source (web, cli, queue), causedBy, rootId, depth — and a chain deeper than ten is refused and logged. The Queue screen shows all of it, with the folding key and the count.
  • An argument that cannot travel as JSON throws in development and under tests. In production it is logged to events, and that task is skipped.
  • Migrations 2026_10_21_000005_add_event_columns_to_queueitems and 2026_10_21_000006_create_queue_idempotency_table. Without them, queued listeners still queue, unfolded.

work loses no minute to a restart

Fixed

  • A task due while work restarted never ran (FW-230). Each pass asks about every minute since the previous pass, but that minute was kept only in memory, so a new process looked at the current minute alone. A deploy restarts the worker, and the old process finishes its pass first, which leaves a gap of one or two minutes. On a day with 21 deploys, a task on 18 7 * * * never ran, and it left no row in scheduleruns, not even a skipped one. The last minute asked about is now kept in var/pramnos-work.lock.minute, beside the lock, and a new process starts after it, capped at Work::CATCH_UP_MINUTES (15) as before.

The outbox deadline sweep gets an index

Fixed

  • mail:flush read the whole mails table every minute (FW-231). Its deadline sweep, UPDATE mails … WHERE status = 2 AND date < ?, has nothing to narrow it on a mails with no index on status. That is an installation whose table predates the framework's migrations. On 35 million rows it took nine seconds a run, with an empty outbox. Migration 2026_10_21_000007_add_status_date_index_to_mails adds (status, date) where no index leads with status, and does nothing where one does: the framework's own idx_mails_status, or one an installation built itself under any name.

Domain events as classes

Added

  • Event::dispatch(object $event): fires the class's EVENT constant, or the class name when it has none, with the object as its one argument. A listener in the request receives that instance itself, models included, so what it changes on the caller's objects is what the caller saves next (FW-233).
  • Pramnos\Event\QueueableEvent (jsonSerialize(): array, static fromQueue(array $data): static): an event object that can reach a QueuedListener. The task carries its data and its class, and the worker hands handle() the object fromQueue() builds. One handle(JobPostCreated $event) therefore serves the request and the queue. debounceKey() and idempotencyKey() are asked at dispatch, with the object.
  • Listener classes typed with their event — handle(JobPostCreated $event) — registered by class name, are called by fire() without implementing ListenerInterface.
  • create:event writes the convention: EVENT (JobPostCreated → jobpost.created), a readonly payload, and jsonSerialize()/fromQueue(). create:listener takes --event= for a typed handle() and --queued to extend QueuedListener.

Changed

  • QueuedListener no longer implements ListenerInterface: no typed handle() could satisfy that interface's handle(mixed ...$args). It is callable instead, and calling it runs it at once, so an instance can still be registered with Event::listen(), and fire() still queues it.

Documentation

  • Queue guide: new section "Domain events as classes", covering when to use a class rather than a bare name, that listeners in the request act on the caller's object, and that a listener which changes other data goes to the queue. Console guide: the new options.

Scheduled tasks an operator may adjust

Added

  • ->adjustable($id, $label, $group, presets:, limit:) on a scheduled task: from the task's page on the Scheduler screen, an operator can pause it, give it another frequency from a fixed list, keep it to an hours window, and set a per-run limit (FW-232).
  • The list of frequencies: every 1, 5, 10, 15 or 30 minutes; every 1, 2, 3, 6 or 12 hours; daily at a time; weekly on a day at a time. No free cron.
  • The hours window may cross midnight. Outside it, and while paused, the task is not due and writes nothing.
  • The limit reaches a command as --limit=N and a callable or a job's handle() as its argument, held to the declared [min, max].
  • Applied without a restart. Settings live in scheduleoverrides (migration 2026_10_21_000008_create_scheduleoverrides_table), keyed by the application's id. work and schedule:run read them once a pass. Back to the schedule in the code deletes the row.
  • Not late. ScheduleStatus treats a paused task, or one outside its hours, as not late, and lists it as Paused since … or Outside hours · next 09:00. Its next run is the first one inside the window.
  • Audited. A save is scheduler.adjusted in the audit log and a reset is scheduler.reset, each with the settings before and after. The task's page says who changed it and when.
  • scheduler.adjust (Scheduler::ADJUST_ABILITY): changes adjustable tasks, and opens the screen to an account that holds it, showing the adjustable tasks only, grouped, with no Run now. Where the application defines no rule for it, admin.scheduler adjusts as well.

Audiences: attributes of a person, and filters over them

Added

  • Pramnos\Audience: the first part of segments, rules and A/B tests (FW-234). A person is a row of users; an application declares what can be asked about one, and a JSON filter of conditions grouped "all" / "any" chooses people by it.
  • Attributes (Attributes::define()): a column of users, a column of a one-to-one table (LEFT JOINed once, only when used), a one-to-many table (asked with EXISTS, so counts stay counts of people), or an SQL expression per driver; typed text, number, date, boolean, list or many, with a label, a heading, choices and an ability. The framework declares account type, language, registered, last sign-in, status, email verified and groups. Attributes::scope() says who is a person at all.
  • Filter: checked when made, with the path of what is wrong; compiled to the query builder; count(), sample(), ids() in keyset pages, matches(), breakdown() per value, and explain() — every condition with ✓/✗ and the person's actual value.

Documentation

  • New Audience guide.

Segments: named audiences kept up to date

Added

  • Pramnos\Audience\Segments (FW-234): a filter with a name, owner and description, and segment_members — who was in it, one row per stay, entered_at to left_at (migration core/2026_10_21_000009_create_segments_tables).
  • Recomputed in full by segments:recompute, which the framework schedules hourly, and per person with recomputePerson() — in the queue, folded per person, on the events an application names with Segments::refreshOn().
  • segment.entered / segment.left as event classes (SegmentEntered, SegmentLeft) on each change. A segment's first recomputation records its members without them.
  • Filters can name segments ({"segment": id, "op": "in" | "not_in"}), computed in dependency order. A segment that would name itself is refused, and one in use — by another segment, or by what an application reports through Segments::usage() — is not deleted.
  • Audience reads fail loudly: Filter and Segments read with getAllOrFail(), so a mistyped side table is an error on PostgreSQL too, not an audience of nobody.

Documentation

  • Audience guide: Segments. Console guide: segments:recompute.

Group membership through one path, with events

Added

  • Pramnos\Audience\Groups (FW-234): add() / remove() for one person or many, addMatching() / removeMatching() for everybody a filter matches, and of() for the person card. Only real changes are written; each flushes the usergroups cache, tells the applications when the group holds a role, and dispatches group.joined / group.left (GroupJoined, GroupLeft) with who did it and from where.

Changed

  • The Groups screen adds and removes members through Groups, so its changes are announced too.

Documentation

  • Audience guide: Groups.

Added

  • Pramnos\Audience\ConsentLists (FW-234): sends and rules name a list, never a field. The application declares sources (ConsentLists::source(): a filter meaning "agreed", and a column for the default); lists over them have a name, a channel, a description and a default for new accounts (migration core/2026_10_21_000010_create_consent_lists_table).
  • narrow() an audience to a list, removes() — how many consent takes out — allows() for one person, and applyDefaults() at registration. An email list always leaves out addresses that unsubscribed from all, and from its own opt-out list.
  • email_optouts attribute: the lists an address left, so a filter can ask about them too. One-to-many attributes take ->matching() for a table keyed on something other than userid.

Documentation

  • Audience guide: Consent lists.

An audience builder and picker with a live count

Added

  • Html\AudienceBuilder::filter() (FW-234): conditions over people's attributes in groups of "all" / "any", with the count beside them as they change, a breakdown by any attribute that has one, and a sample linking to each person's card.
  • AudienceBuilder::picker() for a send: in any of these segments and groups, except these, a filter for this send alone, and the consent list — showing what each step takes out. Audience\Picker compiles the state to one filter: final(), counts().
  • /admin/Audience: preview, explain and catalog, behind the administration floor; a filter about an attribute the account may not see is refused.
  • pf-audience.js, and the pf-audience-* styles in the three themes.

Fixed

  • A filter whose group holds only empty groups compiled to (); empty groups now ask nothing.
  • pramnos-testing: querySelectorAll() no longer hands a text node to the selector matcher, so a script that appends text beside elements can be tested.

Documentation

  • Audience guide: The builder and the picker.

Added

  • Segments (/admin/Segments, People → Segments, FW-234): the library of segments and groups — now, +entered / −left over seven days, used by — and a segment's page with its newest members, Work it out now, and Why is this person in or out?. Saving works it out at once; a segment in use is not deleted.
  • Consent lists (/admin/ConsentLists, People → Consent lists): every list with its channel, source, default and count.
  • Html\AudiencePanel::person(): segments and groups on a person's card, shown on the Users screen in the three themes.
  • Changes to segments and lists are in the audit log with before and after.

Documentation

  • Audience guide: The screens.

Added

  • The mass-message compose form carries the audience picker (FW-234): in any of these segments and groups, except these, a filter for this send, a consent list for the channel — with the live count and what each step takes out. When anything is picked, the message's criteria are ['audience' => <state>] and the account criteria are not used.
  • MassMessageAudience::resolve(), preview() and describe() read that state through Audience\Picker; a state that no longer checks reaches nobody, never everybody.

Documentation

  • Email guide: the mass-message audience. Audience guide: the picker on the compose form.

What people did: person events, recorded from event classes, pages, email and the past

Added

  • Person events (FW-234 part B): person_events — per person and event, how many, the first and the last — and person_event_log, each occurrence with its properties (migration core/2026_10_21_000011_create_person_events_tables).
  • No call of its own: an event class implementing Audience\PersonEvent is recorded for its person whenever Event::dispatch() fires it, in the queue (RecordPersonEvent), then the person's segments are refreshed, folded. PersonEvents::recordLater() for the rest.
  • Conditions over them: ever, never, at least / at most N — of all time or in the last M days — last or first within N days, first or last before / after a day; explain() gives the count, first, last and the count in the window. The builder lists them under What they did.
  • The past: PersonEvents::backfillFrom() from an application's table (Unix or datetime), backfill(), forget().
  • Named pages (PersonEvents::page()), matched on every rendered page for whoever is signed in; email opens and clicks as email.opened / email.clicked of the account the mail log ties the message to.
  • audience:prune-events, scheduled daily: the log past audience.event_log_days (365).

Documentation

  • Audience guide: What people did. Queue guide: a PersonEvent is recorded on dispatch. Console guide: audience:prune-events.

Audiences do nothing until their tables exist

Fixed

  • An application holding the audience migrations back (migrations.exclude) is untouched by FW-234: a dispatched PersonEvent, Segments::refreshOn(), named pages and email opens and clicks queue nothing and raise nothing; segments:recompute and audience:prune-events succeed with one line; the Segments and Consent lists screens leave the menu and say they are not installed. Before this, a person event queued a task that failed in the worker.
  • Audience\Installed answers whether the tables are there, remembered five minutes per process.

Documentation

  • Audience guide: Before the migrations run.

Rules: when, who, what, until — run in the queue

Added

  • Pramnos\Audience\Rules (FW-234 part C, first piece): rules as versioned JSON — a trigger (an event, entering or leaving a segment, a schedule, once), who (a filter), steps (actions, waits, waits for an event with a timeout, branches on a filter or on whether a message was opened, clicked or delivered, splits), an exit and a goal; entry first or every (migration core/2026_10_21_000012_create_rules_tables).
  • Engine: Engine::listen() in an application's init puts a listener on each event the running rules ask about; every step is a queue task (audience.rule, answered by the framework) that moves a run only from where it is. Sending hours, the rule's consent list and frequency caps (audience.caps) before every message; shadow mode logs what would have been sent.
  • Actions: email (a mail template into the outbox, its origin in mails.moduleinfo), bell, push, group_add, group_remove, attribute, notify_admin, webhook; Actions::define() for an application's own.
  • Audit and run log: every change in the audit log with before and after; every decision of every run in rule_log, kept audience.rule_log_days (90). rules:run every minute for schedules. A segment or consent list a rule reads is not deleted.

Documentation

  • Audience guide: Rules. Console guide: rules:run.

Trying a rule before it runs

Added

  • Audience\Rules\Tester (FW-234 C): why() — would this person come in, and what would each step meet; simulate() — how many the last 7 or 30 days would have brought, and the messages per channel; testSend() — a step's action sent to oneself with a real person's details; diff() — who a new version takes in and leaves out.
  • Rules\Context carries a recipient for a test send (to(), isTest()); the framework's actions send there and fill in the person's details.

Documentation

  • Audience guide: Trying a rule before it runs.

Messages on the site: banners, popups and cards

Added

  • Audience\SiteMessages (FW-234 C): a banner, popup or card for one person, until closed or expired, its first showing recorded (migration core/2026_10_21_000013_create_site_messages_table).
  • Html\SiteMessages::render() in the three scaffolded layouts, for whoever is signed in; the close button is a form to /SiteMessages/dismiss/{id}, back to the page.
  • The rules' site_message action.
  • Audience\Explained: a condition of an explanation in words — values by their labels, yes or no, days, an event's count and last time. A segment's "why" uses it: an account type reads Professional, not 2.

Documentation

  • Audience guide: Messages on the site.

The Rules screen: the list, the editor, a rule's boxes and its tools

Added

  • /admin/Rules (People → Rules, ability admin.rules, FW-234 C): the rules with how each is doing; the editor (Html\RuleEditor, pf-rules.js) — when, who and the exit in the audience builder, the steps as boxes with their branches, goal, consent, hours, caps; a rule's page with its boxes and how many went through each (Html\RuleCanvas), switching, why for an account, had it been on for 7 or 30 days, a test send to oneself, versions compared, and its log.
  • The pf-rule-* styles in the three themes.
  • The Email Log's message page says which rule sent a message — Sent by rule …, version …, step …, linked — read back with Rules\Context::parse().

Changed

  • Tester::testSend() refuses a step that is not a message: a group or attribute change tried on oneself would be real.

Documentation

  • Audience guide: The Rules screen.