13 October 2026¶
Thirty-eight entries today:
Response::send()leaves an earlier status line alone- Recording sends is an application setting; a missing SMTP host says so
Paginationreads a page number from a URL whatever it is- Closing a connection another instance closed is a no-op
- A table's footer search reads nothing undeclared and stays within its table
- A date field is ten characters wide, and its
form-controlis the project's to drop - A model in JSON is its data
- Reading — and taking — a column's search value before the Datasource runs
- A multiple-choice column filter reads as its values
- A user that did not load is saved as a new one
- Language models: a client, a tool loop, a call log and one admin screen
/admin/Aishows the application's own sections- An empty order is no order
- Conversations with a model: memory, background questions, schedules and consent
- A conversation's assistant: its bag, its clock, and a limit only on adding
- Screens for conversations: a JSON API, a page, a widget and three Svelte components
- A resync keeps the components a project changed; conversation screens under
llm/ - The conversation screen: a waiting line, word of an answer, and the application's own formats
- The administrators' analyst: questions about the installation, from its own figures
- A reserved word is quoted wherever a column is named
- A push is recorded at the moment it was sent, in every zone
- Ask: the analyst's own page at
/admin/Ai, and a conversation page that answers in place - The language-model tables are read under the installation's prefix
- A page loading a framework script the project never published says so
- An application's own chat tables take a conversation outside any organisation
- A migration that made its table is not declined for want of its hypertable
- Every framework migration runs on an installation with a table prefix
- MariaDB: dropping a CHECK, and a column's CHECK after its COMMENT
- The framework's queries name its tables as its models do
- A queue worker's task sees the settings as they are now
- A guest inside the administration area signs in and comes back to it
- The analyst counts days in the application's zone, and its rule about people is the application's
- A table in an answer is readable in every theme
- Example questions above a new conversation
- The analyst writes its own SQL, and files to download
- A conversation reads like one: whose, when, and a copy button
- A short export shows in the answer; a table copies into a spreadsheet
- Charts in an answer
Response::send() leaves an earlier status line alone¶
Fixed¶
Response::send()calledhttp_response_code()unconditionally. On PHP 8.5 that call, after aheader('HTTP/1.1 404 Not Found')a legacy controller had written, has no effect and logs "Calling http_response_code() after header('HTTP/...') has no effect" — once per such response. It now sets the status only when it differs from the one in effect.
Documentation¶
- Framework Guide,
ResponseAPI reference: whatsend()does with a status already set, and how a front controller passes a controller's own status intomake().
Recording sends is an application setting; a missing SMTP host says so¶
Added¶
'mail' => ['record' => false]inapp/app.phpturns off themailsrow that everyEmail::send()writes. An application that keeps its own row per message got two — one measured sixteen pairs in its first hundred rows — and the staticEmail::sendMail(), which builds its ownEmail, had no way to turn it off.$email->recordToMails = falsestill does it for one message.
Fixed¶
- With no
smtp_host, a send failed with Symfony's "The mailer DSN is invalid." — the DSN built from an empty host issmtp://user:pass@:0, so the message pointed at the credentials. It now fails with "SMTP not configured: smtp_host is empty."
Documentation¶
- Email Guide: the
recordsetting beside the audit log, and the missing-host error under SMTP settings. Upgrade Guide: two rows.
Pagination reads a page number from a URL whatever it is¶
Fixed¶
new Html\Pagination($pages, $page, $url)tookintfor both numbers, so a non-numeric page taken from the URL —/page/abcfrom a crawler — was aTypeErrorand a broken page. Both acceptint|string|nulland are read with(int):'3'is page 3, anything else page 1.
Documentation¶
- HTML Components Guide, Pagination: how the numbers are read.
Closing a connection another instance closed is a no-op¶
Fixed¶
Database::close()calledmysqli_close()/pg_close()on whatever link it held. Two instances given the same link withoutclone— a legacy and a modern handle over one connection — both own it, so the second destructor threw "mysqli object is already closed": a fatal in the log at shutdown, after the response had gone, twenty times a minute on one production site. A link that is already closed now makesclose()returnfalse.
Documentation¶
- Database API Guide, Cloning a
Database: a link shared some other way.
A table's footer search reads nothing undeclared and stays within its table¶
Fixed¶
Html\DatatablewithfooterTextSearchemitted a blur handler that restoredasInitVals[…], which nothing declared: every empty footer box that lost focus threw "ReferenceError: asInitVals is not defined". The focus/blur pair imitated a placeholder by writing the label into the value; the boxes carry aplaceholderattribute, so it is gone.- The footer
keyupwas bound with$("tfoot input"), every footer box on the page, so with two tables typing under one filtered both. It is bound to its own table's footer.
Documentation¶
- HTML Components Guide, Filtering one column: the boxes filter their own table only.
A date field is ten characters wide, and its form-control is the project's to drop¶
Added¶
component_classes.date(defaultform-control): the classHtml\Dateputs on its field.''leaves it off. Bootstrap's.form-controlisdisplay:block; width:100%, so outside a grid column the field spanned the page — fifty-one administration views in one application — and the class was hard-coded.
Fixed¶
- The field carries
size="10", so with no class it is ten characters wide rather than the browser's default. Its classes are escaped.
Documentation¶
- HTML Components Guide, Date and time: the width and the class.
A model in JSON is its data¶
Fixed¶
ModelimplementsJsonSerializable, returninggetData().json_encode()walked a model's public properties, and$controllerleads to the application and back to the model: "Recursion detected",false, and a 500 from every API response holding a model one level down. On one production site that was a user's record (its package, a model in a field) and their tickets (departments and the user, arrays of models).
Documentation¶
- API Guide,
getData()and the columns it returns: what a model becomes in JSON. Upgrade Guide: one row.
Reading — and taking — a column's search value before the Datasource runs¶
Added¶
Datasource::columnSearch(int $column)returns a column's search value from DataTables 1.10 (columns[N][search][value]) or 1.9 (sSearch_N) alike.render()translated the first into the second only when it ran, so an application building its own condition beforehand — one has about 470 reads ofsSearch_N— found nothing on a 1.10 request.Datasource::takeColumnSearch(int $column)reads it and keepsrender()from applying it as aLIKEon the column as well, for a filter the application turns into its own condition.
Documentation¶
- HTML Components Guide, Filtering one column: both methods.
A multiple-choice column filter reads as its values¶
Fixed¶
- A
<select multiple>column filter posts an array —fnFilter(jQuery(this).val(), N), sent ascolumns[N][search][value][]=….Datasource::columnSearch()read it as'', dropping the filter, and the 1.10 translation cast it to the wordArray, whichrender()applied asLIKE '%Array%'. Both now read it as its non-empty values joined with commas —'3,7'— the form a 1.9 client sent the same choice in.
Documentation¶
- HTML Components Guide, Filtering one column: a multiple choice.
A user that did not load is saved as a new one¶
Fixed¶
new User($id)for an id with no row kept_isnew = 0and the defaultuseridof 1, so itssave()ranUPDATE users … WHERE userid = 1— the Guest row, or nothing — and then wrote its details against that id. An application creating accounts fromnew User($_SESSION['uid'])lost every sign-up after upgrading, with nothing logged. A user that did not load is now new, and saving it inserts a row with its own id.- A save whose
UPDATEfinds no row — the user was deleted after loading — writes nothing, details included, and records why in_errors. Zero affected rows alone is not taken as missing: MySQL reports that for an unchanged row too, so the row is looked for first.
Documentation¶
- Authentication Guide, Loading Users: what a user that did not load is. Upgrade Guide: one row.
Language models: a client, a tool loop, a call log and one admin screen¶
Added¶
Pramnos\Llm, behind a newllmfeature:Client::ask()andaskWithTools()over the OpenAI-compatible providers (OpenAI, DeepSeek, Gemini, Mistral built in; an application adds and overrides them inapp.php), withTools::fromMcp()turning MCP tools into the loop's tools. The loop forbids tools on the last round and tells the model not to claim what no tool did; a due tool is forced before the end, and a verifier can send a wrong answer back once. Answers are plain arrays.- Policies an application plugs in, each defaulting to "allow":
Policy\Limits(plans, quotas; told what each round spent),Policy\Consent,Policy\Router(the request's organisation, its own provider or key) andPolicy\DataRule— data a tool tags (Tools::tag()) that may only go to some providers moves the answer to one that may take it, or is withheld. llm_calls: every call, failures included, with its cost frozen at write time from editable unit prices (llm_prices, seeded) andnullfor a model nobody priced.llm:prunekeeps 90 days and is scheduled daily.Billing\OpenAiCostsreads what OpenAI billed./admin/Ai(admin.ai): providers, keys (encrypted, never printed back), models, features, prices, OpenAI billing and the call log — the one screen for these settings, which an application extends with its own sections ('admin_sections',Llm\Admin\Section) rather than a second screen.init --features=…,llmwrites the admin wrapper.- The settings and the table keep the names an application that already ran this code uses
(
llm_*,llm_calls); a table that exists is kept, and gainstokens, filled from each row's usage.
Documentation¶
- New: the Language models guide. Console Guide:
llm:.
/admin/Ai shows the application's own sections¶
Fixed¶
- The sections an application declares in
'llm' => ['admin_sections' => [...]]were saved but never drawn. The controller handed them to the view assections, which is alsoView's own protected property: the assignment went through__set, and the template, running in the view's scope, read the real — empty — property. The variable isappSections. Any view variable named after a property ofViewhas the same problem (sections,path,model,_layout, …); the test that caught this renders the template through a realView.
An empty order is no order¶
Fixed¶
Model::_getList($filter, '')ordered by primary key descending, asnulldoes. An empty string is the caller saying "no order" — the framework's first_getList()read it that way, and wrappers declaredgetList($filter = '', $order = '')relied on it — so every such list came back upside down: one application's invoices, newest at the bottom for years, newest at the top after upgrading.''now adds noORDER BY;nullis still newest first.
Documentation¶
- ORM Guide: what the order argument's two empty values mean. Upgrade Guide: one row.
Conversations with a model: memory, background questions, schedules and consent¶
Added¶
Pramnos\Llm\Chat: conversations kept per person (Conversations,Scope), what a model remembers about them (Memory), questions answered by the queue worker (Asks, achat_asktask the worker knows without being told) and questions asked again on a schedule (Routines, run every minute byllm:routines, telling the owner withRoutineReady). What a conversation is about — prompt, tools, checks, what an answer produced — is an application'sChat\Assistant;BaseAssistantanswers every hook with a default.Policy\StoredConsent: agreement to a provider kept inai_consents, checked only for a provider markedrequires_consent.Pramnos\Llm\DocumentText(Word, OpenDocument, text, Markdown and email to words, and their pictures) andPramnos\Email\MailMessage(an email read into its parts).- Tables
chat_conversations,chat_messages,chat_memory,chat_asks,ai_consentsandai_routines, under thellmfeature; an application that already has them keeps them, andchat_conversationsandchat_messagesgaintagsandextra. Queue\Worker::FRAMEWORK_HANDLERS: the framework's own task types, answered without being registered; an application registering the same type wins.
Documentation¶
- Language models guide: Conversations. Email Guide: Reading an email.
A conversation's assistant: its bag, its clock, and a limit only on adding¶
Fixed¶
- What
Assistant::question()put in the turn'sbagwas lost beforetools()andanswered()ran: the turn was built again after it. The bag is carried over. RoutinesaskedroutineRefusal()the same question to offer the tools, before every run and when one was added, so a limit on how many may be active either stopped every run once reached or was not enforced. It is nowroutineRefusal(Scope $scope, bool $adding = false),truewhen one is created or resumed — resuming a paused routine adds an active one too.
Added¶
Assistant::timezone(Scope): the clock a schedule said in words is meant on, for theschedule_tasktool.BaseAssistantanswers the server's zone.
What breaks in subclasses¶
- An assistant implementing
Chat\Assistantdirectly — not throughBaseAssistant— needstimezone(Scope $scope): string, and itsroutineRefusal()must takebool $adding = false.
Screens for conversations: a JSON API, a page, a widget and three Svelte components¶
Added¶
Pramnos\Llm\Chat\Api\Routes::register($router)mounts the conversation API — conversations, asking (queued, or answered in the request underanswer_in_request), following a queued question, memory, scheduled questions and agreement — at/chat…, with paths an application can move.Chat\Api\ChatControllercarries the hooks:refusal(),beforeAsk(),storePicture(),extra(),limits(). A refusal for want of agreement saysconsent_required, so a screen asks on the spot./chatfor an application without a SPA:Chat\Web\ChatPageController, views in the three themes,assets/js/pf-chat.jsto wait for an answer in place — and a page that refreshes itself without JavaScript — pluspartials/chat_widgetfor a question from any page.init --features=…,llmwritessrc/Controllers/Chat.php; the user menu gains Ask AI.Chat\Markdown::toHtml(): a model's Markdown as HTML, escaped first.- For a Svelte SPA with the
llmfeature:Chat.svelte,Scheduled.svelteandAiConsent.svelte, withlib/chat.js,lib/markdown.js(markedand DOMPurify, added to package.json) and their tests — at init, and byproject:resync --spa-components --all.Chatis extended through props and snippets (extra,canAsk,onRefusal,message,composer,aside,t) rather than edited.
Documentation¶
- Language models guide: Screens for conversations. Upgrade Guide: one row, for the menu item.
A resync keeps the components a project changed; conversation screens under llm/¶
Fixed¶
project:resync --spa-componentsreplaced every shared component that differed from the framework's — a DataTable keyed for duplicate rows, a Field with its own debounce — and printedupdated. Whatinit,create:crudand the resync write is now recorded by hash inapp/shipped-files.json; a file changed since, or never recorded, is reportedkept (locally changed)orkept (not written by the framework)and left alone.--overwritetakes the framework's copy anyway.- The conversation screens shipped at
components/Chat.svelte,lib/markdown.js,__tests__/Chat.test.js— paths an application's own chat screen and tests already used. They are now undercomponents/llm/,lib/llm/and__tests__/llm/. - The conversation API and page checked the assistant's connection before the router ran, so an
organisation on its own provider or key was told nothing was set up, or asked to agree to the
installation's provider. They check the routed one:
Conversations::connection().
Added¶
- Agreement per feature:
GET/POST/DELETE /chat/consent?feature=…(chatby default), each on the provider the router sends that feature to;AiConsent'skind. A feature the application does not have is a 422. AiConsent'snotice(consent)snippet — andChat'sconsentNotice— for the agreement's wording, which is the application's legal text.ChatController::routinesAnswer($scope, $answer), to decorate the scheduled-questions list.
Documentation¶
- Console Guide: what
--spa-componentsreplaces, and--overwrite. Language models guide: thellm/paths, per-feature agreement, the notice, the routed provider. Upgrade Guide: one row.
The conversation screen: a waiting line, word of an answer, and the application's own formats¶
Added¶
Chat.svelte:waitingLine(ms)for what shows while an answer is worked on,onanswer(messages)after each answer is read back, a bindablequestion, aplaceholderof its own,formatDate,numberFormat, andconversationHref(id)to make the conversation list links.- What it remembers: an empty memory says Nothing remembered yet. — its own key, so an application can say what is worth telling it — and a remembered preference is marked as one.
Documentation¶
- Language models guide: the new props.
The administrators' analyst: questions about the installation, from its own figures¶
Added¶
/admin/Ai/analyst: administrators ask about the installation — accounts, sessions, organisations, mailing lists, email, push, the queue, logs, health and model spend — and a model answers fromPramnos\Llm\Analyst's read-only tools, behind the AI screen's usertype. The page is the conversation page at the analyst's addresses ($this->urls,$this->heading;data-pageforpf-chat.js).Analyst\Redactor: email addresses, tokens, Luhn-valid card numbers, phone numbers and IP addresses masked in every tool result; aggregates never name a person, and an email subject is listed only when it was sent to three or more. Theuserlookup is taggedpersonal.'llm' => ['admin_tools' => [...]]for an application's own figures, and'llm' => ['analyst' => ['assistant' => …]]for anAnalystAssistantthat knows the business.chat_conversations,chat_memory,chat_asksandai_routinesgainspace(chatby default);Chat\Scopetakes it as a third argument and reads within it, so an administrator's analyst never appears in their Ask AI. A queued analyst question is answered by the analyst.Chat\Attachments::uploaded(),TakesQuestions::consentState()andrecordConsent(), shared by the conversation page and the analyst.
Documentation¶
- Language models guide: The administrators' analyst, and under Policies, that an explicit
organization_id => nullis kept by a router.Policy\Router's own docblock says the same.
What breaks in subclasses¶
- A subclass of
Chat\Web\ChatPageControllerorChat\Api\ChatControllerthat declares its ownconsentState()orrecordConsent()with another signature: rename it — the trait now has both.
A reserved word is quoted wherever a column is named¶
Fixed¶
select(['usage'])onllm_callsfailed on MySQL:usageis reserved there and was not in the builder's list of reserved words. It is now, with the other MySQL-only reserved words found as column names (rank,row,lock,read,schema,system, …). A reserved word is also quoted when qualified (c.usage) and as an alias (tokens AS order); a mixed-case alias and a raw expression are left as written.
Documentation¶
- Query Builder Guide: Reserved words.
A push is recorded at the moment it was sent, in every zone¶
Fixed¶
Push\Logwrotesentas PHP's local time without an offset into atimestamptz, which the database read in its own zone — on a PHP in Europe/Athens and a database on UTC, every push was recorded hours in the future, and the windows ofstats()andprune()shifted with it. All three now use UTC with the offset.
Documentation¶
- Push Guide: what
sentholds. Upgrade Guide: one row, for rows written before.
Ask: the analyst's own page at /admin/Ai, and a conversation page that answers in place¶
Changed¶
/admin/Aiis Ask, the administrators' analyst; the language-model settings are/admin/Ai/settings, a link from it./admin/Ai/analystand/admin/Ai?show=problemsredirect. The analyst's actions areask,asked,delete,forgetandconsent, behind the same ability and usertype as the settings.
Added¶
/admin/Ai/api/…: the analyst in the conversation API's shape, for a screen of the application's own — the session's token inX-CSRF-Tokenfor anything but GET.Analyst::headerButton(): an Ask link beside the admin search, for those who may open it; in new projects' layouts.Analyst::floor()reads the application'sAdmin\Controllers\Ai.pf-chat.jsasks in place on/chatand on Ask: files picked, dropped or pasted as chips, a thinking line with the application'swaiting_lines, and the answered page swapped in without a reload. The views draw which tools an answer read, the document a question carried, the ⚙ with model, limits and memory, the context bar, and a ✕ per conversation.Chat\Attachments::fromJson(), shared by the conversation API and the analyst's.
Documentation¶
- Language models guide: The administrators' analyst and The page, without a SPA; the settings' address throughout. Upgrade Guide: one row.
What breaks in subclasses¶
- An
Admin\Controllers\Aithat overridesdisplay()overrode the settings: it issettings()now.
The language-model tables are read under the installation's prefix¶
Fixed¶
Pramnos\Llmqueriedchat_conversations,chat_messages,chat_memory,chat_asks,ai_consents,ai_routinesandllm_callsby their bare names, while their migrations create them with the installation's prefix. On an installation with one,/admin/Aianswered 500 (Table '….chat_conversations' doesn't exist), and so did every conversation, the call log and the scheduled questions. Every query names them with#PREFIX#.
Added¶
TablePrefixInQueriesTestalso reads the migrations: a table a framework migration after the baseline creates under a plain name must be queried with#PREFIX#. It had derived prefixed tables only from names written with#PREFIX#somewhere, which a feature that never did was invisible to.organizations,notificationsandqueuestatsare enumerated as not yet converted.- An integration test that builds the
llmtables with a prefix, on MySQL and PostgreSQL, and goes through each.
Documentation¶
- Language models guide: the tables carry the installation's prefix.
A page loading a framework script the project never published says so¶
Fixed¶
- The conversation pages load
assets/js/pf-chat.js, whichcomposer updatedoes not put in a project's web root; an application that updated got the pages as plain forms and a 404 nobody saw.assetUrl()now logs a warning the first time a page asks for one of the framework'spf-*.jsthat the web root lacks, namingproject:resync --js --all.
Added¶
- The
framework_scriptshealth check:degraded, with the files, while a shippedpf-*.jsis missing from the web root.Application\FrameworkScriptsanswers which are shipped and which are missing. - A test that every
assetUrl('assets/js/pf-…')in the framework's views and layouts names a scriptproject:resync --jspublishes.
Documentation¶
- Health Guide:
framework_scripts. Language models guide: publishingpf-chat.jsafter an upgrade. Upgrade Guide: one row — runproject:resync --js --all.
An application's own chat tables take a conversation outside any organisation¶
Fixed¶
- The framework's
llmmigrations leave a table that exists, so an application that had its ownchat_*andai_*tables withorganization_id NOT NULLkept that constraint — and the analyst, which always writes outside an organisation, failed on its first question:/admin/Aianswered 500. A migration (2026_10_13_000012) makes the column nullable on those six tables where it is not, keeping its type.
Added¶
- The
llm_tableshealth check: with thellmfeature,degradedwhile a conversation table is missing, lacksspace, or refuses a nullorganization_id, naming each.
Documentation¶
- Language models guide: what the migrations do to an application's own tables. Health Guide:
llm_tables. Upgrade Guide: one row — runmigrate.
A migration that made its table is not declined for want of its hypertable¶
Fixed¶
- On a backend without TimescaleDB, the
authmigrations that create a table and then convert it —twofactor_attempts,user_activity_log,user_consents,data_processing_records,gdpr_requests,tokenactions— were declined, "nothing was applied", after creating the table; the nextmigrate, finding it, recorded them as applied. They now treat the conversion as the extra it is, and are applied the first time. ifCapable()on an engine (ENGINE_POSTGRESQL,ENGINE_MYSQL,ENGINE_MARIADB) no longer declines the migration when the engine is another: PostgreSQL-only indexes skipped on MariaDB are not work left undone.
Added¶
SchemaBuilder::whenCapable(): run when capable, otherwise nothing, and the migration is not held back — for an extra that something else repairs when the capability arrives.
Documentation¶
- Schema Builder Guide:
ifCapable()orwhenCapable(). Hypertable Guide: the migration pattern useswhenCapable().
Every framework migration runs on an installation with a table prefix¶
Fixed¶
- Nineteen framework migrations named tables in raw SQL —
public.applications,`usertokens`,applications.oauth2_application_grants,ALTER TABLE users— and so failed, or skipped their work, on the first installation with a table prefix; theapplicationsgroup,oauth2_application_grantsand its views,user_organizations,user_app_authorizations, the consent trigger, the RBAC functions and the slow-call views among them. Every table in a migration's raw SQL is now named through$schema->quoteTable();oauth2_application_grantsis built with the schema builder outright. SchemaBuilderresolved the table being built and not the one a foreign key referenced, so->on('applications')named an unprefixed table, and->on('authserver.x')a database on MySQL. It resolves both.add_missing_foreign_keys_to_existing_tablesandadd_missing_indexes_to_existing_tablesusedschema('public'), which on MySQL becomes{prefix}public_*, and looked constraints and indexes up by bare name. A foreign key between columns of different types —INTagainst anincrements()'sINT UNSIGNED— is skipped with the reason instead of failing the batch; new MySQL installations get the referencing columns unsigned, so the keys are created.CorsMiddleware::fromApplicationSettings()was raw SQL over the same names; it is a query-builder query. On PostgreSQL it could read neither column:cors_originsis aTEXT[], which never parsed, so every application allowed*, andcors_enabledcame back'f', which PHP reads as true.
Added¶
PrefixedFrameworkMigrationsTest: every framework migration on a fresh MySQL and PostgreSQL installation with a prefix — nothing fails, nothing is declined but for a missing capability.TablePrefixInQueriesTestrefuses a framework table named in a migration's raw SQL, which reaches the statements a fresh run does not:down()and the other dialect's branch.
Documentation¶
- Migration Guide: Raw SQL Migrations — what the builder names, and naming tables in what it
does not. Schema Builder Guide: the referenced table, and matching an
increments()on MySQL. Upgrade Guide: two rows — CORS on PostgreSQL, and migrations on a prefixed installation.
MariaDB: dropping a CHECK, and a column's CHECK after its COMMENT¶
Fixed¶
allow_jwt_bearer_in_oauth2_application_grantsandopen_webhook_event_typesdropped their CHECK withDROP CHECK, which MariaDB refuses; and MariaDB keeps a CHECK written on a column as part of the column, named after it, whichDROP CONSTRAINTdoes not reach either. Both go through the newSchemaBuilder::dropCheck().create_oauth2_application_grants_tablewrites its CHECK named and table-level,chk_oauth2_grant_type, so the next migration can drop it on every database.- The schema grammar wrote a column's
CHECKbefore itsCOMMENT, which MariaDB refuses; the COMMENT now comes first.
Added¶
SchemaBuilder::dropCheck($table, $name).- A
mariadbservice (MariaDB 10.11), profile-gated, and./dockertest --mariadb, which starts it and runs themariadbgroup: the grants migrations and every framework migration on a prefixed installation. Excluded from the main suite.
Documentation¶
- Testing Guide: MariaDB, when a change is dialect-sensitive. Schema Builder Guide: Dropping a CHECK constraint.
The framework's queries name its tables as its models do¶
Fixed¶
- About forty queries over
organizations,applications,queueitems,notificationsandqueuestatsnamed the table bare, while the models over the same tables — throughModel::resolveTableName()— and the migrations that create them use the installation's prefix. On a prefixed installation the queue screen andqueuecounts, the organisation names, lists and role screens, a user's active tokens, the OAuth joins and account erasure read tables that do not exist, beside the ones the models wrote to. They name them with#PREFIX#. - Four raw statements among them are query-builder queries: a user's active tokens and the
device-flow and session user lookups (which also named
usersandusertokensbare), the queue's average run time, and the status tool's queue counts.
Added¶
TablePrefixInQueriesTestexempts no table any more.PrefixedRuntimeTablesTest: on a prefixed MySQL and PostgreSQL installation, an organisation's name, a user's tokens with their application, and the queue's counts.
Documentation¶
- Query Builder Guide: Table prefixes — a model prefixes its table, the builder does not. Upgrade Guide: one row.
A queue worker's task sees the settings as they are now¶
Fixed¶
queue:process --daemonread the settings once and kept them for its whole life, so a provider or API key an administrator saved was never seen by the tasks that followed — "API key saved", then "no API key". Before each task it claims, the worker callsSettings::refresh()andLlm\Connection::forget(). An idle worker reads nothing.
Added¶
Settings::refresh(): forgets what was read from the database, keeps the settings file's keys, and reads the table again on the nextgetSetting().
Documentation¶
- Framework Guide, Runtime settings: a long-lived process. Queue Guide: A task sees the settings as they are now.
A guest inside the administration area signs in and comes back to it¶
Fixed¶
- A guest on a protected controller inside the area was sent to
login?return=Ai— the path with the area's prefix taken off — and after signing in landed on the site's/Ai. Both the area's own guard and a controller'saddAuthAction()refusal now return to the whole path,/admin/Ai.
Added¶
AdminArea::useLogin($url): the area's own sign-in page, in place of the site'slogin.AdminArea::loginUrl()andloginRedirect($requestUri)build the redirect.
Documentation¶
- Routing Guide, administration area: where the guest returns, and
useLogin(). Upgrade Guide: one row.
The analyst counts days in the application's zone, and its rule about people is the application's¶
Fixed¶
- The analyst's prompt said dates are UTC, and its tools counted UTC days — so for an
installation in Athens "yesterday" ran from 03:00 to 03:00. A day is now a day in
Analyst::timezone():'llm' => ['analyst' => ['timezone' => …]], or the application's default zone. Each answer's window names its zone. - The rule about people was written into the prompt, so an installation whose administrators find accounts by name could only contradict it — and the model refused, quoting it.
Added¶
AnalystAssistant::peoplePolicy(), the framework's rule by default.Analyst::timezone().Window::from()takes the zone as a third argument.
Documentation¶
- LLM Guide, The administrators' analyst: the zone, and
peoplePolicy().
A table in an answer is readable in every theme¶
Fixed¶
Markdown::toHtml()drew a bare<table>, and the Bootstrap conversation page styled none of it, so the columns ran together. A table now carriestable table-sm, its cells' spacing and rules inline for a theme with no such class, and a scroll of its own. It is drawn that way by the renderer, so the conversation views a project already copied need no resync.
Documentation¶
- LLM Guide, The page, without a SPA.
Example questions above a new conversation¶
Added¶
'llm' => ['chat' => ['suggestions' => […]]]for/chat, and['analyst' => ['suggestions' => […]]]for Ask: buttons above a conversation not yet started, in each theme.pf-chat.jsputs the one clicked in the question box —data-pf-chat-suggestion, no inline script. An existing project takes the views and the script withproject:resync; a view it changed is kept.
Documentation¶
- LLM Guide, The page, without a SPA.
The analyst writes its own SQL, and files to download¶
Added¶
- Three analyst tools, off until the application turns them on —
'llm' => ['analyst' => ['sql' => true, 'export' => true]]:schema: tables, columns, keys, and the application's notes about them ('schema' => […]);sql: one SELECT, up to 200 rows to the model;export_csv: up to 100,000 rows to a CSV file with a byte-order mark. The model gets the link, the count and five rows.
Analyst\ReadOnlySqlguards every statement:- the read-only lexer;
- no function that waits, locks or reads files;
- a read-only transaction, rolled back;
- a time limit on each engine (
sql_seconds, 30 by default); - a
LIMITwhen the statement has none.
Analyst\Exportskeeps the files for seven days, under names short enough to survive redaction./admin/Ai/export/<name>serves a file only to the administrator it was made for.Database\ReadOnlyConnection: thedatabase_readonly_dsnaccount, shared bydb-inspectand the analyst.Analyst::tools()andmcpTools()take the administrator's id.AiController::adminId().
Documentation¶
- LLM Guide, Its own SQL, and files to download.
A conversation reads like one: whose, when, and a copy button¶
Changed¶
- On
/chatand Ask, a question and an answer were the same kind of card, so the reader could not tell their own words from the model's. Now:- a question is a bubble on the right, and an answer is text on the left;
- each sits under "You", or the assistant's name, with its time;
- the time is
<time datetime="…Z">, shown bypf-chat.jsin the reader's zone as "today 22:50" or with its date, and in UTC without script; - ⧉ Copy under an answer copies its Markdown;
- a question asked in place is drawn the same way.
- A project takes the views and the script with
project:resync.
Added¶
'llm' => ['chat' => ['name' => …]]and['analyst' => ['name' => …]].Chat\Web\Stamp.
Documentation¶
- LLM Guide, The page, without a SPA.
A short export shows in the answer; a table copies into a spreadsheet¶
Added¶
export_csvreturns a list of up to'llm' => ['analyst' => ['export_inline' => 100]]rows whole (rows_inline), with a hint to show it as a table beside the link. A longer one is still the link, the count and five rows.export_csvtakes an optionaltitle. The file is named<admin>-<title>-<16 hex>, with Greek transliterated, and downloads as<title>-<date>.csv.- ⧉ Copy table over each table in an answer: its rows, tab-separated, which a spreadsheet pastes as cells.
Fixed¶
- Under a Bootstrap 3 admin theme the question's bubble had no background, since
bg-body-tertiary rounded-4is Bootstrap 5. The bubble, and one asked in place (data-question-style), now carries an inline fallback.
Documentation¶
- LLM Guide, Its own SQL, and files to download and The page, without a SPA.
Charts in an answer¶
Added¶
- A fenced block with the info string
chartholds one JSON spec. The fields aretype(bar,hbar,line,area,pie,doughnut),labels,seriesof numbers, and optionallytitle,subtitle,x,y,stackedandmin. Markdown::toHtml()makes such a block<div data-pf-chart>with the code inside, and leaves an invalid spec as code.Markdown::chart()checks a spec.pf-chat.jsdraws a chart as SVG, with no library: axes with round steps, a key, and tooltips.window.PfChat.charthands the drawing to an application's own library.Markdown::CHARTS: what the model is told. The analyst's prompt carries it throughAnalystAssistant::charts().
Fixed¶
pf-chat.jsstarted the page before thevars below the call had their values. It now starts last.
Documentation¶
- LLM Guide, Charts.