Files
james.maesandClaude Fable 5.2 a9ebe64883
maven / build (push) Successful in 7m48s
build / maven (push) Successful in 7m48s
fix(process): only a reviewed process runs; a person confirms what they were shown; an independent review of the process tools closed
After an independent review of the process tools. Each change is described
by the guarantee it gives.

- Only a reviewed process runs, decided when the application runs. A host
  registers the processes it reviewed for the assistant in
  AssistantQBitConfig (withReviewedProcesses, or
  withReviewedProcessesResource for a file on its classpath). With no list,
  only a table's own bulk edit and bulk delete are runnable: they act on
  records of a table the person may already change through the assistant.
  With a list: exactly the listed processes and those two, each still
  subject to every never-runnable rule, to an administrator's narrowing row
  and to the person's own permission. RunnableProcessReview stays as the
  way a host notices drift in its build, and now also fails when the
  reviewed file is not the registered list.
- An input is only a field a screen asks a person for. A screen's field
  whose name the process keeps for its own working is never an input
  (ProcessControlNames: the inputs of the process's backend steps, and a
  fixed set this QBit owns, checked by a test against the QQQ version it
  builds with). The person is told the field cannot be set through the
  assistant; the process stays runnable for its other fields.
- A person confirms what they were shown. A run stops at every later screen
  that has something to show (a form, values to read, a text, a list of
  records), and going on is a second card that shows it. Only a screen with
  nothing to show is passed. The screen a run starts at is on the first
  card.
- A run is over exactly the records its card counted. A filter is resolved
  when the run is proposed, to at most 500 primary keys (more is refused,
  for the person to narrow); the confirmed run is sent those keys and does
  not start when one of them is gone.
- A process that may run as a durable job is created waiting, or not run:
  where the application does not offer the operation that creates a job,
  the run is refused.
- Text of the application reaches a model only inside the data envelope
  every tool result uses: a process's messages, what a screen shows, the
  application's message for a refusal or a failure. That holds for a
  result, for a card's lines, for the summary a later request keeps of a
  process step, for what a later request is told became of a confirmed run,
  and for the thread's own message about it (ToolLabels.forModel,
  MessageParts.modelText).
- A card goes on only with the run as it stood when the card was made: a
  run counts a version up at every step and stop, and an older card of the
  same run is refused.

One expectation of ChatToolSetTest changes: the scenario
everythingAsAnotherPerson no longer holds the four process tools. That host
registered no reviewed list, and its person may run processes of another
QBit and change no table, so the assistant runs no process for them.
ToolCallChecksTest is unchanged. No new tool. AssistantStore is still the
only code that calls data actions.

README, UPGRADE.md, CHANGELOG.md, CLAUDE.md and section 4.5 of
docs/actions-and-mcp.md say the same.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 17:51:29 -06:00

83 KiB

Changelog

Unreleased - The assistant runs a process the signed-in person may run; four review findings closed

Builds package A4 of the actions design (docs/actions-and-mcp.md, section 4), with four findings of an independent review, each described by the guarantee it gives. One behaviour change needs a host's attention before deploying: a table's own bulk edit and bulk delete become runnable through the assistant, and any other process once the host lists it (see UPGRADE.md).

After an independent review of the process tools, each change described by the guarantee it gives:

  • Only a reviewed process runs, decided when the application runs. A host registers the processes it reviewed for the assistant in AssistantQBitConfig (withReviewedProcesses, or withReviewedProcessesResource for a file on its classpath). With no list, only a table's own bulk edit and bulk delete are runnable (they act on a table the person may already change through the assistant); with a list, exactly the listed processes and those two, each still subject to every never-runnable rule, to an administrator's narrowing row and to the person's own permission. RunnableProcessReview stays as the way a host notices drift in its build, and now also fails when the reviewed file is not the registered list.

  • An input is only a field a screen asks a person for. A screen's field whose name the process keeps for its own working is never an input (ProcessControlNames: the inputs of the process's backend steps, and a fixed set this QBit owns and checks against the QQQ version it builds with). The person is told the field cannot be set through the assistant; the process stays runnable for its other fields.

  • A person confirms what they were shown. A run stops at every later screen that has something to show (a form, values to read, a text, a list of records), and going on is a second card that shows it; only a screen with nothing to show is passed. The screen a run starts at is on the first card.

  • A run is over exactly the records its card counted. A filter is resolved when the run is proposed, to at most 500 primary keys (more is refused, for the person to narrow); the confirmed run is sent those keys.

  • A process that may run as a durable job is created waiting, or not run. Where the application does not offer the operation that creates a job, the run is refused; it is never run on confirmation instead.

  • Text of the application reaches a model only as data. A process's messages, what a screen shows and the application's message for a refusal or a failure are inside the data envelope every tool result uses: in a result, in a card's lines, in the summary a later request keeps of a process step, and in what a later request is told became of a confirmed run.

  • A card goes on only with the run as it stood when the card was made. A run counts a version up at every step and stop; an older card of the same run is refused.

  • Process tools (new package tools/process): list_processes, describe_process, run_process, get_run_status. The assistant runs a process the signed-in person may run themselves, as that person, after they confirmed the run on a card. The list and the description come from the person's own metadata, asked as them: a process they may not run is never listed or described, and is refused by name in the words used for a process that does not exist.

  • A run is only ever proposed. run_process checks the inputs against the fields the process's screens declare (an unknown input, a value of the wrong type and a missing required input are refused before a card exists; no value is made up), counts the records as the person, and stores one pending action of the new kind process_run. The card shows the process, every input, the records (how many, of which table, which) and the steps that stop for the person. Nothing runs until the person confirms, and nothing switches that off.

  • The confirmed run is the person's. It runs through the UI API with the confirming request's session: a permission revoked after the proposal refuses the run with the application's own answer. Each record of the card is looked for again as the person when the run starts, and a run does not start when one of them is gone.

  • Two ways to run, by the process's own metadata. A process the application reports as eligible runs as a durable job: the assistant creates it and never starts it (core creates a job from the assistant channel waiting for the person and refuses to start it from that channel; the assistant sends no expected row count, so core never starts it by itself either). Any other process runs through its own init and step operations until it ends or needs the person: at a review, and at any later screen that has something to show, it stops, and going on is a new proposal with a card of its own.

  • A long run holds no pending action open. The action ends when the job is created or when the run ended, stopped, failed or was still working after the time the confirmation waits. get_run_status reports where a run stands and never moves one on.

  • What is never runnable through the assistant, in one place (ProcessRunnability): a process denied to the assistant channel, the assistant's own, one whose data access is not READ_WRITE, a hidden one (except a table's bulk edit and bulk delete), one no screen offers, one on a table the assistant may not change, one that is not on the host's reviewed list (except those two bulk actions), and one with a screen the assistant cannot present.

  • A result carries the process's own words as data: its summary lines, what a screen it stopped at shows (less every field the assistant may not see the values of), and the application's own message for a refusal or failure, redacted like every tool result and inside the data envelope.

  • A host decides which processes run, and can see drift (review 1). defaultProcessAccess was already READ_WRITE; it is the ceiling for a process, and the host's registered reviewed list decides which processes run under it (see above). RunnableProcessReview.assertReviewed(qInstance, person, file) fails a host's build when a process could be listed and is not, when a listed process is gone, and when the file is not the registered list.

  • An administrator is warned before a read-only table opens (review 2). UPGRADE.md now says, in bold, to run a query before upgrading: it has the predicate of the data-access migration and shows the rows that migration removes, so an administrator can keep a table read-only by editing its row first. A test runs the query from UPGRADE.md and the changeset against the same rows.

  • No tool says confirmation can be turned off (review 3). The descriptions of create_record, update_record and delete_record and the comment on ToolKind.PROPOSE no longer say "unless the administrator turned confirmation off". The cached request prefix changes once.

  • No model is called without a spending limit in force (review 4). The routing classifier now asks BudgetService.limitInForce for its own model before it is called. The administrator's Test connection is the one exemption, decided and documented with what bounds it (administrator only, a fixed prompt, no tools, 32 output tokens, a usage row).

  • A scheduled task is offered run_process only with its write approval.

  • Tests added: the process tools against the fixture's UI API (both ways to run, each stop and each kind of later screen, a screen that cannot be presented, a denied process, revoked permission, a failing process, a long run, records fixed at the proposal and the cap of a filter, a record gone at confirmation, two cards of one stop, no spending limit, an administrator's row), what is sent to the application call by call (no value under a name a process keeps for itself, primary keys and never a filter, no run where a job cannot be created), the reserved names against the QQQ version built with, the reviewed list with no list, an empty one and a list, the build-time helper, the query of the upgrade notes, the classifier, the connection test, and a confirmed update that would move a record into a record-security key the person lacks (refused by the application, nothing changes).

  • UI API extension assistant 1.10.0 (additive): AssistantActionV1.processName, processLabel; changes for a process_run.

  • Not in this change: report and schedule tools (package A5); the panel's process card and its "start the job in the same click" (the panel's package); the possible values of a process input (the value is passed as given and the process validates it).

Unreleased - Writes follow the signed-in person; no answer without a spending limit; a fourth review closed

Builds package A2 of the actions design (docs/actions-and-mcp.md, sections 2 and 3): the assistant may propose any change the signed-in person may make themselves, by default, with the defaults declared in versioned code. It also closes five findings of an independent review of the tool boundary, each described by the guarantee it gives. Two behaviour changes need an administrator's attention before deploying: see UPGRADE.md.

  • Writes follow the person, with no settings row (owner decision, 2026-11-12). The data access of a table or a process is declared in code: AssistantQBitConfig.withDefaultTableAccess and withDefaultProcessAccess (both READ_WRITE by default) and a pin per object, withTableAccess(table, mode) and withProcessAccess(process, mode). READ_WRITE grants nothing: it lets the assistant propose what the person may do themselves, and every change still waits for that person's confirmation card. Before, a table was READ until an administrator stored a READ_WRITE row.
  • An administrator's assistant_data_access row can only narrow. READ makes a table read-only for the assistant, NONE hides it. A row never raises what the code says: not a code default of READ, not a pin, not a table whose metadata keeps it from AI. DataAccessService.Decision.source says what decided ("default", "pinned in code", "administrator setting", "sensitive table"), and the Data access card and every refusal show it.
  • The assistant's own tables and processes are NONE by a pin in code that nothing lifts: every assistant_* table (policy, providers, models, prices, budgets and overrides, data access, profiles, skills, knowledge sources, scheduled tasks, conversations, tool calls, pending actions, usage) and every process of the QBit, whoever asks, with a READ_WRITE row and a host pin on it. This is in addition to core's channel wall.
  • The write tools are offered to a person who may change something. create_record, update_record and delete_record (and the bulk-load save and run tools) are offered when the UI API says the person may create, edit or delete records of some table whose data access allows writes; save_analysis when they may save an analysis. The question is asked as the person, once per offer, within toolCallTimeoutSeconds; no answer means no write tool. GET /config features.writes is now that same answer for the person (it was "some table has a READ_WRITE row"). The person's permission for a particular change is still decided by the UI API when the confirmed change runs, with the screen's own status and message.
  • Settings that cannot be read allow nothing. When the administrator's rows cannot be read and none were ever read, every table is NONE until they can be (the fallback used to be the old default). Rows read earlier stay in force.
  • "Add rows for every table" is retired (assistantDataAccessAddDefaults, DataAccessDefaultsProcess), with its help. It wrote the old default out as rows, which would now read as restrictions. The shipped changeset db/liquibase/qbit-assistant/assistant-data-access-follow-user.changelog.sql removes the READ rows it wrote that nobody changed, and no other row. The Data access card on the Overview shows the effective mode and its source for every table without storing anything.
  • No answer without a spending limit (owner decision, 2026-11-12). The assistant refuses a person for whom no spending limit is in force: a turn is refused before anything is stored and before any model call (HTTP 429, code BUDGET, "The assistant is not answering because no spending limit is in force. An administrator must set or switch on a budget (Assistant > Budgets) before it can be used."); so are every later model call of a running answer, every tool call, every embedding request and a scheduled turn (whose run records the sentence). A limit is in force when a hard limit above zero is declared in the code configuration or on the policy row, or a budget row is switched on, has a limit above zero and applies to the person (BudgetService.limitInForce). A row that is switched off, a zero limit, a row for somebody else and a soft limit alone do not count. Restoring a limit restores service on the next request. A row whose limit is not above zero is no longer evaluated at all (it used to fail the evaluation).
  • A pending action is never marked expired over its outcome (review 1). Expiry is now one of the decisions an action can have: it goes through AssistantStore.decidePendingAction (a claim with the decision EXPIRED), like confirming and cancelling, so only an action that is still pending and unclaimed is expired. ToolCallService.expiredIfDue decides from the stored action and the runtime's clock, never from a copy read earlier. AssistantStore.expirePendingAction(id) is replaced by expirePendingAction(userId, id, now), which answers whether it expired the action.
  • No action stays RUNNING for ever (review 2). The retention process first ends confirmed changes that were left RUNNING for more than an hour (the server stopped while one ran) as FAILED, with "Outcome unknown: this change was started and its end was never recorded. Check the record before asking for it again." and a tool-call log row; they are then removed, with what they kept of the record, like any other decided action once older than approvalRetentionDays. AssistantRetentionStep.purge(runtime, now) does both steps; the process reports pendingActionsFailed.
  • A person is the session the authentication module resolved (review 3). SignedInPerson.ofSession(qInstance, session) used to check the session's id and then keep the object it was given. It now makes the person from the session the module resolved: a permission added to a session by hand gives the person nothing, and a session kept from earlier does not keep a permission that has since been taken away. Only the time zone is carried over.
  • The assistant's own reads go through the service (review 4). UiApiToolbox.Context.forRead(sessionUUID, timezone, gateway, policy, credentials, turnId) is gone: there is no public way to make a tool context from a session id, a gateway and a policy. ToolCallService.readContext(person, timeout) makes the context of a read the assistant makes for itself (the starter prompts, the knowledge index), after checking the person, assistant access and the kill switch, with the policy it reads itself and the runtime's gateway. Context.forRead(timeout) and forReadWhateverTheToolSwitches() only derive a narrower context from one the service made. These reads have no tool-call log row, by decision: nothing of a starter-prompt read reaches a model, a knowledge hit is read again inside a search_knowledge call that has its row, and an index run reads every record of its source as the administrator who started it and has its process record. The UI API's access log names the assistant as the client for all of them.
  • The published test-jar holds the fixture and the harness, nothing else (review 5). It is built from com/kingsrook/qbits/assistant/testing/** only. The helpers this module's own tests use to reach behind the tool-call service (RuntimeInternals, ToolCallServiceInternals, Toolboxes) and the tests themselves are no longer published. The provider modules use AssistantTestFixture, TurnRunner, GoldenTasks, PresentationGoldenTask, EvalSupport and CloudProviderTestServer, all in that package, and are unchanged.
  • The decision cannot be replaced by a subclass. AssistantStore.decidePendingAction, expirePendingAction and failAbandonedPendingAction are final. The store itself stays open to subclassing, which this repository's tests use to make a read or a write fail.
  • New on ToolCallService: offersAny(person, toolNames), readContext, expiredIfDue, failAbandonedActions. ToolOffer carries the credential of the person's request. StarterPrompts.forUser(person, policy, ...) is gone (the caller asks the service for the context); BulkLoadToolFamily no longer takes the data toolbox; UiApiToolbox.definitions(policy, boolean) is definitions(policy, Set<String> writableTables) and UiApiToolbox.anyWritableTable() is gone.
  • UI API extension assistant: no new operation and no changed field; the description of features.writes says what it now means.
  • Not in this change: any new tool (process, report and schedule tools are the next packages); the panel. The write tools' descriptions still end "unless the administrator turned confirmation off", which no longer can happen: they are part of the cached request prefix and are left for a change that touches it on purpose.
  • Tests: DataAccessPrecedenceTest (the precedence as a table, for tables and processes; the pin on the assistant's own objects; sensitive tables; unreadable settings), WritesFollowThePersonTest (the tool lists for a person who may and may not change a table, with no settings row; narrowing to read-only and to not at all; a row cannot widen past the person, a pin or the assistant's own tables; the screen's own refusal), DataAccessFollowUserChangelogTest (the changeset twice on a fresh schema, on written-out and hand-written rows, and without the table), NoSpendingLimitTest (each way a limit is not in force; restored on the next request; a limit removed during an answer), PendingActionExpiryTest (a list that read the action before it was confirmed; a confirmation and an expiry at once, twenty rounds; an action left running), SignedInPersonTest (a hand-made permission, a revoked permission), AssistantOwnReadsTest, TestJarContentsTest, and new rules in ToolCallArchitectureTest. ToolCallChecksTest passes unchanged. ChatToolSetTest passes with the definitions unchanged; see UPGRADE.md for the one line of its expected list that changed and why.
  • Test only: the random canaries of TranscriptRedactionTest and ScheduledTasksTest have no digits any more. The scan for a leaked canary looks for every five characters of it, and five digits in a row also turn up by chance in a stored timestamp's fraction of a second, which failed a build that leaked nothing.

Unreleased - One decision per pending action, a tool surface closed at compile time, cards with before and after

Closes what a third independent review of the tool boundary found, and builds the pending-action package of the actions design on top (before and after values, kinds, a list, retention). Each fix is described by the guarantee it gives.

  • A pending action is decided once. Confirming and cancelling are one decision, made by inserting the action's row into the new table assistant_pending_action_claim, whose pending_action_id is unique: of any number of requests for one action, at the same moment or later, on one server or several, exactly one runs it (or cancels it) and every other is told it is already handled. The claim used to be an unconditional status update followed by a re-read, which let two simultaneous confirmations both run. Whatever fails after the claim (a status that cannot be written or read back, a tool that throws) ends in FAILED or EXECUTED with a tool-call row, never in an action left RUNNING. ToolCallService.cancel(person, id) is new; POST /actions/{id}/reject goes through it.
  • A tool cannot be run around the tool-call service, by the compiler's word. For code outside the assistant's packages: ToolCallService.execute, log and the new withdraw are package-private (the turn engine is their only caller); ToolCallRequest, ConfirmedToolCall and UiApiToolbox.Context are final classes without a public constructor, made only through factories that need the service's permit (ToolCallService.Permit, which only the service can create and never hands out; a factory called without it throws); AssistantRuntime no longer has public getters for the tool registry, the UI API gateway or its tool families, and ToolCallService.getRegistry() is gone. The runtime hands the registry and the gateway to the collaborators it builds. A host still registers its own family with withToolFamily and implements AssistantTool unchanged. New ToolCallService.offers(person, toolName).
  • A person is one person. SignedInPerson.ofSession(qInstance, session) replaces ofSession(session): the authentication module resolves the session's own id, as it will when the tools call the UI API with it, and the person is made only when that is the user the session names. A session put together with one user and another's id is refused. The turn engine no longer makes a person from a session. new AssistantApi(runtime, session) makes none until it needs one, and refuses (403) to start a turn, confirm, cancel or explain access for a session that fails the check.
  • Only the credential the session was resolved from is forwarded. SignedInPerson.ofRequest took every credential on the request; it now forwards the one session setup used (in its order: in-process token, sessionId cookie, sessionUUID cookie, x-api-key, Authorization).
  • A call that was cut short leaves nothing to confirm. When the turn engine does not take a call's own outcome (it ran out of time, was stopped, failed, or could not be processed), the user is shown no card; the service withdraws what the call proposed (EXPIRED, claimed) and refuses a proposal the call makes afterwards.
  • The policy a call runs under is read fresh. execute reads the policy row once per call, never from the cache; a tool switched off is refused on the next call. Without a row the code defaults apply, as the design says (a row only narrows). The README states what policyCacheSeconds still covers.
  • The hard budget is read on every call with one aggregate query per budget. BudgetService summed every usage row of the period in memory; AssistantStore.sumUsage now asks the database for the sums.
  • The input kept in the tool-call log is redacted with customRedactionPatterns too. It was redacted with the built-in patterns only.
  • Cards show before and after; what was approved is what is written. update_record and delete_record read the record as the user when they propose (a record the user cannot read gets the read's own refusal and no card). The card has actionKind, the record's label, and per field the value before and after (a delete: what the record holds), after the exposure rules of get_record. The proposal keeps a version of the record (a SHA-256 over the whole record as the user can read it, never the values). On confirmation the record is read again: changed, gone or unreadable means the change is refused and the record is left as it is. Stricter than the design, which compares only the fields being changed: a digest of a single field could be searched for its value, a digest of the whole record cannot.
  • Kinds. assistant_pending_action.action_kind and tools.registry.ActionKinds: record_create, record_update, record_delete; any other proposal's kind is its tool's name, so process runs, schedules and reports are added later without a schema change.
  • A list. GET /assistant/actions?status=&conversationId=: the user's own pending actions, newest first, expiry evaluated on read. UI API extension assistant 1.9.0; AssistantActionV1 gains optional fields only.
  • Retention. approvalRetentionDays was declared, validated and documented, and never read. assistantRetention now deletes decided and lapsed pending actions older than it, with or without a conversation.
  • Schema: three columns and two indexes on assistant_pending_action, and the table assistant_pending_action_claim. See UPGRADE.md.
  • Not in this change, by the design's package list: the channel and client_name columns of a pending action (they arrive with the MCP proposals) and the panel's new card.
  • Tests: PendingActionDecisionTest (two confirmations held at the same point run the change once; confirm or cancel, never both; a failure after the claim; the policy read fresh; the budget read without a usage row; the redaction patterns), CutShortProposalTest, PendingActionsTest (before and after, hidden values, the changed record, the list, retention), SignedInPersonTest, and HostToolFamilyTest, which is compiled from a package outside the assistant's, runs a host's family through the service, and compiles each former way around the service to show the compiler refuses it. ChatToolSetTest and ToolCallChecksTest pass unchanged.

Unreleased - The tool-call service owns the policy, the identity and the confirmation

Closes what an independent security review found after "The tool-call service makes its own checks". None of it was reachable through the chat, which built everything correctly; it is closed before a second caller (an MCP route) uses the service. The rule: every tool call runs as the signed-in person with their current permissions and budget, never as the assistant; a refusal happens before any data action; every call that ran has a log row with redacted input.

  • Every change always shows a confirmation card, and nothing can switch that off (owner decision, 2026-11-12). PolicyService.ToolPolicy no longer has writesRequireConfirmation, and UiApiToolbox always proposes a create, update or delete. Behaviour change for a host that had set Writes require confirmation to off on the policy row: writes now wait for the card like everywhere else. The assistant_policy.writesRequireConfirmation column and entity field stay in place and are no longer read (no migration; nothing for a host to apply); the field's help text says so.
  • A caller cannot supply a policy (review 1). The service reads the policy itself (PolicyService.current(false).tools(), narrowed for a scheduled task's conversation exactly as the chat narrows it) and builds the tool Context itself. A caller hands it a new tools.registry.ToolCall (person, call id, tool name, input) and nothing else. Before, a caller could pass new ToolPolicy(false, Set.of(), 200, false) and get every tool, 200 rows, no PII redaction and writes without confirmation.
  • A session cannot be mixed with another person's credentials (review 2). New SignedInPerson binds a session to the credentials the tools forward to the UI API. It has no public constructor: SignedInPerson.ofRequest(Context) takes the session the route's session setup resolved and the credentials of that same request (only AssistantSpecs may call it, ArchUnit), and SignedInPerson.ofSession(session) forwards nothing but that session's own id. AssistantApi.withCredentials and TurnEngine.withCredentials are gone (new AssistantApi(runtime, person), TurnEngine.withPerson, which refuses a person of another session); new AssistantApi(runtime, session) is unchanged. The gateway in the tool context is always runtime.getUiApiGateway().
  • The hard budget is read on every call (review 3). It was remembered for the life of a scope, and the scope was whatever object the caller passed. The check only reads (no budget event, no alert, the chat's warning notice stays with the model call that reports it). executeAndLog(call, conversationId, hipaaRoute) now makes the scope itself.
  • A write runs only from its stored pending action (review 4, 7). ToolCallService.executeConfirmed(ConfirmedToolCall), confirmationRefusal and logConfirmed are replaced by one entry, confirm(person, pendingActionId): the service loads the action for that person, checks expiry, status and that the proposing turn has finished, refuses while the kill switch is on or the policy no longer allows the tool (the action stays pending), claims it once, runs it, marks it and writes the log row itself. The caller chooses no table, record or values. Whatever fails after the claim (a tool that throws, stored values that are not JSON) ends in FAILED with a row; before, the action stayed RUNNING forever with no row. AssistantApi.confirmAction is a thin caller and the only one (ArchUnit). The route's answers are unchanged. The confirm route never re-read the record to compare it with the proposal, and still does not: the UI API decides with the record as it is at confirmation.
  • The toolboxes cannot be called around the service (review 5). UiApiToolbox.execute, executeConfirmedWrite, executeConfirmedAnalysis, BulkLoadTools.execute and executeConfirmed, KnowledgeTool.execute and PresentResultsTool.execute are package-private. AssistantRuntime.getToolbox(), getKnowledgeTool() and getBulkLoadTools() are gone; each family is handed its toolbox (AssistantRuntime.ownToolFamilies() is now an instance method). New ArchUnit rules: no class outside tools.. calls into it except through the registry (allow-list: the runtime's wiring, the value types, UiApiToolbox.notAWriteTool, and the new UiApiToolbox.read); only the service constructs a ToolCallRequest or a ConfirmedToolCall; only AssistantApi calls confirm; only RagService and the knowledge tool call RagService.read.
  • New UiApiToolbox.read(context, callId, toolName, input): the assistant's own reads (starter prompts, re-reading a knowledge hit). It runs list_tables, describe_table, count_records and get_record and refuses every other name, so it cannot propose or change anything. StarterPrompts and RagService use it; nothing else may (ArchUnit). AssistantApi reads "is any table writable" from the new DataAccessService.anyWritableTable().
  • A call that ran always has its row (review 6). TurnEngine.executeTools writes each started call's row in a per-call finally. An interruption of the engine while calls run is passed on only after the rows are written (before, no row was written). A runtime exception while a result is classified, shown or logged (for example a tool's inputForLog) costs neither that row nor the later ones; the result is then withheld from the model. A call that was cut short gets up to one second, in all, to hand back what it did: when it finished after all, the row is of its real outcome, with the operation, status, audit link and pending action it left behind (before: "Timed out" and nothing else); the model and the panel are still told it took too long. ToolCallService.log no longer throws and returns whether the row was written; a row that cannot be written for a call that left something behind (a proposed write, an audited change, a confirmed write) is logged at ERROR, not WARN.
  • The scope is checked, not believed (review 8). Before a tool runs, the service checks that the conversation and the turn a scope names belong to the call's person (and the turn to that conversation); a call that names someone else's is refused (assistant:request) and its row does not carry them. The row's client channel is worked out by the service; ToolCallScope.clientChannel() is gone. hipaaRoute stays the caller's to say, because only the caller knows which model or client receives the result (javadoc of ToolCallService and ToolCallScope.hipaaRoute).
  • AssistantQBitConfig.withToolFamily throws IllegalStateException when it is called after the QBit was produced (review 9); it was silently ignored.
  • For a host or another QBit with its own ToolFamily: AssistantTool.execute(ToolCallRequest) and executeConfirmed(ConfirmedToolCall) are unchanged. A tool must not construct either record itself.
  • Stricter than before, in the chat: the policy a tool call runs under is the administrator's row as it is when the call starts, not the copy the turn read when it began (a tool switched off mid-turn is refused at once); a turn whose person goes over a hard budget while its tools run has the remaining calls refused (assistant:budget).
  • Tests: ToolCallServiceTest (one per review item, calling the service directly), new ToolCallRowsTest (rows of a call that ran and timed out, of a runtime exception while logging, of an interrupted engine; all three fail on the code before), ToolCallArchitectureTest (the rules above). ChatToolSetTest and ToolCallChecksTest pass with their expectations unchanged (ChatToolSetTest builds a ToolPolicy without the removed argument). UiApiToolsSecurityTest.testWriteDeniedIsTheSameDenial and testDenialMatrix ran writes with confirmation off; they now propose and confirm, and compare the confirmed write's status and message with the UI's.

Unreleased - The tool-call service makes its own checks, for any caller

  • engine.ToolCallService no longer trusts its caller for a check it can make itself. On every call, before the tool is reached, it checks that the request is whole (a session, a context made for that same session, a policy), that the person has assistant access, the kill switch (read fresh), the hard budget, and that the tool is offered to this person now under the request's policy. The chat's turn engine made these checks around the service; a caller with no chat turn would have skipped them. The service's javadoc lists what a caller still has to supply: rate limiting, how many calls a request may make and for how long, and classification.
  • execute refuses a tool that is not offered. Before, only the turn engine's own list of allowed tools did, so a direct call ran navigate or search_help although the policy had switched them off (the kernel tools have no policy check of their own).
  • A request without a policy is refused. UiApiToolbox, KnowledgeTool and BulkLoadTools treated a missing policy as "every tool is allowed"; they now refuse too.
  • One call cannot mix two people: a request whose context was made for another session than its own is refused, for a call and for a confirmation. ConfirmedToolCall carries the confirming session.
  • New ToolCallService.executeAndLog: runs the call and writes its assistant_tool_call row, redacting the input itself, for a refused call as for one that ran. The redaction and the row moved out of the turn engine (TurnEngine.redactInput is gone; what a presentation logs as its input is the tool's inputForLog). The turn engine still runs its calls in parallel under its own time limit and writes the rows in call order, so it uses the two halves of that path, execute and log(request, outcome); an ArchUnit rule keeps every other class on executeAndLog. The chat's log rows are unchanged.
  • Confirming a pending action (POST /actions/{id}/confirm) is refused while the kill switch is on (503 KILL_SWITCH) and when the policy no longer allows the tool (403). Both are checked before the action is claimed, so it stays pending. Before, a pending write could be confirmed while the kill switch was on.
  • Tool families are registered explicitly. The classpath scan of com.kingsrook.qbits.assistant.tools is gone: any jar with a public ToolFamily under that package was registered and offered. The QBit's own families are the list AssistantRuntime.ownToolFamilies(); a host or another QBit registers one with the new AssistantQBitConfig.withToolFamily(ToolFamily) before the QBit is produced. The runtime logs the registered tool names once at startup (Assistant tools registered) and refuses to start on a duplicate name or when there is no tool. ToolRegistry.discover is replaced by ToolRegistry.of(runtime, families).
  • The tool list is the same bytes in every JVM. ToolRegistry.definitions now sorts the keys of every object of an input schema. A schema built with Map.of (search_knowledge, part of the bulk-load profile schema) iterated in an order that differs between JVMs and the provider mappings serialise in iteration order, which defeated prompt caching across pods and restarts. The order of required and other lists is unchanged. Visible effect: properties are listed alphabetically in every tool's schema, and the cached request prefix changes once with this version.
  • Stricter than before, in the chat: a turn whose model call takes the person over a budget row's limit (assistant_budget, state STOPPED) now has the tool calls of that same response refused (assistant:budget) when they are the turn's first; before, they ran and the next model call stopped the turn. The policy's own hard limits already stopped the turn before its tools. Likewise a tool call that starts after the kill switch went on, and before the running turn's own poll noticed it, is refused (assistant:killSwitch) instead of run; the turn is cancelled either way.
  • New BudgetService.stoppedReason(userId, active): whether a budget has stopped a person, read without storing a budget event or raising an alert.

Unreleased - One tool registry (no behaviour change); built against QQQ 4.1.0-RC.32

  • The assistant builds against QQQ 4.1.0-RC.32 (was RC.25), the version applications pin. Nothing had to change for it; the chat's tool list is byte for byte the same under both versions.
  • Every tool is now registered once, in tools.registry.ToolRegistry: its name, its kind (READ, PROPOSE, DISPLAY), the checks it runs, its definition (description and input schema) and how one call of it runs. The chat's tool list (ContextBuilder.tools), a tool call of a turn and a confirmed write (POST /actions/{id}/confirm) all look a tool up there; none of them chooses a tool family any more.
  • "Run one tool call for this person under this policy, and log it" moved out of the turn engine into engine.ToolCallService, which the confirm route uses too: the refusal of a tool that is not offered, the call itself (with the person's own session), and the assistant_tool_call row.
  • Nothing a person, a model or an administrator sees changed: the same tools are offered in the same order with the same definitions, a call goes through the same checks in the same order, refusals read the same and the same log rows are written. ChatToolSetTest holds the tool list of the code before this change and compares it; ToolCallChecksTest passes unchanged on the code before and after.
  • A tool family is a class under com.kingsrook.qbits.assistant.tools that implements ToolFamily and has a public no-argument constructor. It is found when the runtime starts; no existing class has to name it. The runtime refuses to start if no family is found or two tools share a name.
  • UiApiToolbox, AnalysisTools, BulkLoadTools, KnowledgeTool, PresentResultsTool and the core kernel keep all of their behaviour; UiApiToolFamily, BulkLoadToolFamily, KnowledgeToolFamily, PresentResultsToolFamily and engine.KernelToolFamily register their tools. ContextBuilder is constructed with the registry (withKnowledgeTool and withBulkLoadTools are gone).

Unreleased - The assistant works for people who do not administer it on a host that enforces table permissions in core actions; a person's own budget (UI API extension 1.8.0)

  • Fixed: on a host with QInstance.enforcePermissionsInCoreActions on, a person with assistant.hasAccess and without assistant.admin was refused before any model call ("Not authorized to access the assistant admin data."). The assistant read its settings (policy, providers, models, prices, budgets, data access) and wrote that person's messages, tool calls and usage through tables whose permission rule is assistant.admin, with the person's session. AssistantStore now marks every one of its calls as the assistant's own service code (withPermissionBypass), the way qbit-collaboration and qbit-backups do.
  • What that does not change: the session is still the person's, so writes are attributed to them and the owner record locks keep every other person's conversations, messages, pending actions and usage out (core does not relax record locks for a bypass). The tables' permission rules are unchanged, so the tables stay closed to that person in the UI, the UI API and the assistant's tools. Every operation of the assistant API still requires assistant access first.
  • A person sees their own spending against their own limit and nobody else's. GET /config has budgets (UI API extension assistant 1.8.0): the budgets scoped to that user or to a permission that user holds, with used, limit, percent, state and when the count starts again. Budgets shared by several people (INSTANCE, PROVIDER, MODEL) are not listed. The same lines ride beside the person's question (<assistant_budget>), so "how much of my budget have I used?" has an answer. BudgetService.own and ownLine are public.
  • New CoreEnforcementTest (fixture instance with core enforcement on): a non-administrator's question with a tool call completes and is accounted to them; the settings tables refuse that person and the assistant's tools do not reach them; one person cannot read another's conversation, messages or model calls, through the API or the store; own budget in /config and beside the question; a person at their own daily limit is refused with the sentence while another person is answered. Without the store change every one of them fails at the first store read.

Unreleased - Save and schedule an analysis with one confirmation; confirmed actions reach the conversation; ask before guessing

  • New tool save_analysis: keeps an analyze_records question as a saved analysis the user owns (data_analysis_saved) and, when the user asked for it to be sent regularly, a daily schedule emailed to them (data_analysis_schedule: DAILY, a local time, an IANA time zone, the confirming user as the recipient). It only ever proposes: ONE pending-action card lists the saved analysis, what it shows, the filter, the schedule, who is emailed and the numbers as they are now. Nothing is created until the user confirms, whatever the policy says about confirming record writes. On confirmation both records are created through the tables' own insert operations with the confirming user's session, so their permissions, owner locks and validation decide.
  • It is offered only where the data-analysis extension is registered and an administrator set data access READ_WRITE on data_analysis_saved; a schedule also needs READ_WRITE on data_analysis_schedule. The user's own permission to create the records is read from GET /metaData before a card is shown.
  • A schedule that could never run is refused when it is proposed (and again at confirmation), with what is missing, instead of failing silently at 9am. The assistant reads the instance (a schedule table; a scheduler on data_analysis_run_schedules) and asks the host through the new AssistantQBitConfig.withScheduledDeliveryCheck(Supplier<List<String>>) for what only the host knows (a service identity and its credentials, mail delivery). The supplier returns what is missing in words a user can read; a check that throws counts as missing. The model is told to offer saving without the schedule.
  • A decided action now reaches the conversation. Confirming a pending action (any tool) adds a message of the assistant to the conversation: "Done. ..." (or "That did not work. ...") with links to the records, which the panel shows and the model reads as history. Earlier proposals also carry what became of them in the next request: confirmed and done, confirmed and failed, cancelled by the user, or expired. Before this the model only ever saw "waiting for confirmation", so work after a confirmation stalled.
  • New rule for every model (rule 14): when a request can reasonably mean more than one thing and the choice changes the result (which table, which field, a clock time with no time zone), ask ONE short question naming the options found, then wait; never ask about what a tool can tell; never propose a change while such a question is open. The user's time zone, when the browser sent it, is now stated in the per-user context so "9am" needs no question. New golden task clarify-ambiguous-field (scripted in every build; the same check runs against a real model in the live evals).

Unreleased - Grouped questions (analyze_records)

  • New read tool analyze_records: group a table by one or two fields (optionally a date field by year, quarter, month, week, day or hour) and compute one number per group: a count, or a sum, average, minimum, maximum or distinct count of a field, with the same filter the other tools take (relative dates included). "How many documents were printed in the last day, by format?" is one tool call, and the groups carry links that open their records.
  • It is one call of the data-analysis qbit's pivot operation (POST /qbit/data-analysis/tables/{table}/pivot, UI API extension data-analysis), made through the same gateway with the user's own session, so table read, the table's analyze permission, field access and record locks decide exactly as on the Analyses screens; a refusal reaches the model with the same status and message. The assistant only narrows it: the table's data access must allow reads, group-by, measure and filter fields must be fields whose values the assistant may see, and the policy's row cap bounds the groups returned.
  • The tool is offered only where the data-analysis extension is registered. The assistant gets no dependency on that qbit (test scope only). Relative dates in an analysis filter need qbit-data-analysis 0.3.0-RC.2 or later (earlier versions answer HTTP 500 for them).

Unreleased - A turn always ends

  • Fixed an answer that stayed on "Thinking..." forever (seen after the tool steps had finished) when the host database stopped answering, for example a connection pool with every connection checked out. The watchdog's heartbeat did its database read and write while holding the engine's monitor and before it checked the deadline, so one blocked heartbeat stopped the deadline from ever firing and stopped finish from running. The deadline is now checked first on every tick without touching the database; the heartbeat, cancel poll and kill-switch poll run on a separate bounded worker that is reported (WARN), interrupted and abandoned if it does not return within two heartbeat intervals; and the engine's monitor is no longer held across database I/O in the heartbeat or while waiting to finalize.
  • A finalize guard ends the live stream with an error event and message.end if the terminal save has not completed within turnForceFinalizeGraceSeconds, so the panel never waits on the database to be told the answer failed. The terminal row is left for lease recovery.
  • New INFO log lines for every phase: Assistant turn started, Assistant model call started / finished, Assistant tool call started / finished, Assistant turn finalizing, Assistant turn finished (no message content). WARN lines when the deadline passes, when the watchdog finalizes a turn, when a heartbeat does not return, and when the guard ends a stream.

Unreleased - Answer visible at the end of the stream

  • The turn's redacted transcript (the question and answer parts, pending actions, stored events) is now written before message.end is sent. The panel reloads the conversation the moment the stream ends, and used to read the empty placeholders ([]) the transcript redactor stores until its flush, so the newest question and answer showed as empty bubbles until the panel was closed and reopened.

Unreleased - Clear reason without a model provider (UI API extension 1.7.0)

  • GET /config checks the server-wide causes before the user's permission: DISABLED, then NOT_CONFIGURED (no usable model provider), then NO_PERMISSION, then KILL_SWITCH. A server with no provider no longer tells users to ask for access (#82).
  • UI API extension assistant 1.7.0: GET /config has message whenever reason is set: a sentence for the user. For NOT_CONFIGURED it says what an administrator configures (an enabled row on Assistant > Providers, for example Anthropic with ANTHROPIC_API_KEY set in the server environment, or a local OpenAI-compatible server such as Ollama).
  • Without a usable provider, calls from a user who also lacks assistant.hasAccess answer 503 NOT_CONFIGURED with that message instead of 403 NO_PERMISSION; users with access keep their conversations, and a new question answers 503 NOT_CONFIGURED.
  • Verified the OpenAI-compatible adapter (kind OLLAMA) live against a local Ollama llama3.3:70b-instruct-q4_K_M, including a tool call (LiveOpenAiCompatibleTest).

Unreleased - Conversation management (UI API extension 1.6.0)

  • assistant_conversation is open to every assistant user (assistant.hasAccess or assistant.admin, through the new AssistantAccessPermissionChecker) in the standard table UI. Rows stay locked to their owner by the userId record lock, so a user only ever sees, updates and deletes their own conversations; admins with the all-access key still see everyone's. The Assistant app (its nav) and every other assistant table stay admin-only.
  • Users change only a conversation's title, pinned flag and status (Active / Archived) through the table; the other fields are the assistant's own. A new title is encrypted in HIPAA mode, including literal text beginning with enc:v1:. Only an exact match to the same record's stored ciphertext is preserved unchanged; internal SYSTEM writes retain their existing storage path.
  • A table delete of a conversation also deletes its messages, turns, turn events, tool calls, feedback, data audits and pending actions (the same rows DELETE /conversations/{id} removes), so nothing is orphaned on a database without the cascading foreign keys. A conversation with an answer in progress is refused.
  • New record actions on assistant_conversation: Archive conversations, Restore conversations and Delete conversations (standard streamed ETL: selected records, Review, Result; same permission as the table; only rows the user can see; delete skips conversations with an answer in progress).
  • UI API extension assistant 1.6.0: GET /conversations items have running (an answer in progress: a RUNNING or CANCELLING turn that is not orphaned); PATCH /conversations/{id} accepts {"read": true} (sets unreadCount to 0); GET /config has conversationsTable: "assistant_conversation" when the session may query that table.

Unreleased - Starter prompts follow access

  • profile.starterPrompts in GET /assistant/config is built per user: up to three prompts generated from tables the user can read that have rows (UI API metadata and count as the user, bounded, current access rechecked on every request, stable order for the day), plus configured prompts only when tagged with tables the user can read ([table] prompt, [none] prompt; untagged lines are dropped). See UPGRADE.md.

Unreleased - Chat turns are not jobs

  • Ordinary chat turns no longer create core tracking jobs: they stay out of the Jobs tray and Jobs tables, and core no longer sends an "Assistant answer finished" notification for every answer. Crash safety is unchanged; it comes from the assistant's own turn table (leased, heartbeated assistant_turn rows, seq-numbered assistant_turn_event replay and the abandoned-turn sweep). The panel's Stop (POST /turns/{id}/cancel) is unchanged.
  • Removed the AssistantRunTracker SPI, JobTrackingRunTracker and AssistantQBitConfig.withRunTracker / setRunTracker / getRunTracker (see UPGRADE.md).
  • Real multi-step work stays a real job: a confirmed bulk load still launches through POST /qqq/v1/processes/{p}/jobs, and its tool result now carries jobHref (the job's /app link) next to jobId, plus a record ref to the job; the confirm response's href links to the job.
  • Scheduled task notifications (output mode Notification or Both) now go through core QAlerts.notify (category Job, the owner as the only audience, a link to the conversation, success/error/warning severity by run status), replacing the reflective call into an older alerts SPI that no longer matched.

Unreleased - Attachments

  • Files attached in the panel (pageContext.attachments, core upload refs) are read for the question: as the signed-in user's own upload, in memory, with per-file and per-question byte and character caps (AssistantQBitConfig.attachments). The type is sniffed from content; executables, archives and unknown binary data are refused. Text, Markdown and JSON are decoded with charset detection, PDF page text is extracted (PDFBox via QQQ core, no scripts, host font mapper preserved), CSV/TSV/XLSX become a header + rows + count preview, and PNG/JPEG/GIF/WebP images go to vision models as image parts.
  • Images are decoded with ImageIO before provider submission, rejecting truncated data and decoder warnings. A 16-million aggregate pixel / 100-frame budget bounds decoded data; original bytes are forwarded. WebP uses the explicit TwelveMonkeys decoder.
  • Content reaches the model in an untrusted uploaded_file block (delimiters neutralized) in the question's user message; the system prompt says it is data, never instructions. A file that cannot be read adds a notice to the answer and never fails the turn. In HIPAA mode attachments count as PHI.
  • Provider SPI: ModelPart.Type.IMAGE (mediaType, base64 data), mapped by the Anthropic, Bedrock and OpenAI-compatible providers. Claude models report vision. ModelPart.toString() never includes text or image data.
  • GET /config: features.attachments, limits.maxAttachmentBytes, limits.maxAttachments, limits.attachmentTypes.
  • System prompt: when create_record / update_record are offered, the model proposes the change instead of saying it cannot create records. ||||||| parent of c5125f9 (fix(assistant): store the transcript before message.end so the panel shows the answer)

Unreleased - Optional provider health

  • An enabled provider that is not the default and not the only route for any configured routing (an enabled profile's simple/complex model, a budget downgrade model or a knowledge source's embedding model, with no fallback on another usable provider) is optional: when it is unreachable, its assistant.provider.<id> and assistant.model.<id> checks report DEGRADED with a message naming the provider requests use instead, so one unfinished local server no longer turns the whole assistant component DOWN. The default provider and sole routes still report DOWN. A failed provider probe still carries reachable=false, so the circuit breaker keeps refusing the provider.
  • New assistant_provider.setup_state (Setup state: Active / Setting up, empty counts as Active). A provider that is Setting up is never routed to (default, profile models, fallbacks, budget downgrades, embeddings) and gets no provider or model health checks; Test connection and Discover models still work. Apply the Provider setup state DDL (UPGRADE.md).

Unreleased - Fail soft (UI API extension 1.5.0)

  • Every message part, tool render and presentation is validated before it is stored or streamed, and again when stored parts and events are read: all render types (records, record, count, widget, history, pending_action, bulk_load_dry_run) and presentation blocks always carry their lists. A render that cannot be repaired is replaced by an error notice ("This result could not be displayed."); unknown part and render types pass through. Problems are logged with the type and a fixed code only.
  • Tool calls have their own limit (toolCallTimeoutSeconds, default 60) besides the turn's (maxTurnSeconds, default 300). A timed-out or failing tool gets an ERROR (is_error) result for the model, an ERROR tool part and an error notice for the user; internal details go only to the log.
  • Every turn ends: terminal events are emitted independently, so a failing emit never skips message.end or the terminal turn row. Conversation detail repairs assistant messages left STREAMING (an orphaned run is reaped; a run that already ended gives its status), and the retention process finalizes runs still RUNNING past the hard stream limit (e.g. after a pod restart).
  • POST /qqq/v1/assistant/client-errors (extension 1.5.0): the panel reports errors it hit (scope, message, optional stacks, route, ids, part/render type, app version, occurredAt). Answers 202 {"accepted": true}; over clientErrorsPerUserPerMinute (default 30) it answers 202 {"accepted": false} and drops the report. Fields are bounded, message and stacks scrubbed (secrets, e-mails, long numbers, tokens, query strings), ids kept only when they are the user's own. Rows go to the new owner-locked assistant_client_error table (Assistant app nav, retention after toolResultRetentionDays), and the assistant.client-errors health check turns degraded at clientErrorWarnPerHour (default 20) reports in an hour. Apply the Fail soft DDL (UPGRADE.md).

Unreleased - Empty renders and stream context (UI API extension 1.4.0)

  • Tool result renders always carry their lists: a records render for a query that matched nothing now has "rows": [] and "columns": [] (QQQ's JSON omitted empty lists, and Next's Assistant crashed the page reading rows.length). Parts and events stored before the fix are completed when read. Extension 1.4.0 documents the shape as AssistantRecordsRenderV1.
  • Streamed text arrives on the provider's own (virtual) thread; the turn's progress updates now run in the turn's QContext, so job progress no longer fails with "Could not flush job progress" (NPE on the missing instance).

Unreleased - UI API 1.6.0

  • UI API extension assistant 1.3.0 (requires QQQ 4.1.0-RC.15, UI API 1.6.0): operationIds, standard error responses with BasicErrorResponseV1, extension tags and trace IDs. Preserve HTTP statuses and assistant-specific error codes.
  • Retain AssistantErrorV1 as a deprecated component for compatibility. Remove dangling null schema references from the two bodyless 204 responses (delete conversation and feedback), with an empty-content workaround for core schema traversal. Success payloads are unchanged; the contract test retains the release baseline with explicit exceptions for the error schema migration and these documentation corrections.

Unreleased - Central health

Added

  • Dynamic assistant.provider.<id> and assistant.model.<id> dependency checks in component assistant, category AI, reusing provider diagnostics and minimal connection calls with deadlines, cost-cap/kill-switch skips and TEST usage accounting.
  • LOCAL breaker reuse of central provider outcomes younger than 60 seconds, with its existing lazy probe as fallback.
  • Generic Assistant health status card linking to filtered System Health; registration, status, accounting, deadline and sentinel-secret tests.
  • QQQ 4.1 compatibility for the header shim: setting extra headers avoids duplicates now that core forwards them too.

Removed

  • The home's independent answering/last-test health indicators and per-provider health/down-until display. Central health owns status and history. Manual Test connection actions, lastTest* fields and the circuit breaker remain.

Unreleased - Jobs tray

  • Track assistant answers in the core Jobs tray through JobTracking, with thinking, tool and writing progress and Stop support.
  • Require QQQ 4.1.0-RC.8 and the host's durable jobs runtime and jobs tables; answers remain untracked when the runtime is absent.
  • Default to JobTrackingRunTracker; opt out with withRunTracker(AssistantRunTracker.NOOP).

Unreleased - In-app help

  • Ship the host-neutral Assistant help bundle through QQQ's native registry, replacing the temporary loader.
  • Cover Assistant tables, fields, actions, widgets and selectors; retain live metadata references and add a bundle coverage gate.
  • Document HOST bundle overrides and remove deployment-specific provider, classification, retention and backup guidance.

Unreleased - Transcript redaction

  • Tokenize classified PII/PHI in stored messages, tool inputs/results, replay events, pending actions and derived summaries using QQQ secured-data tokens. Preserve eligible model requests and live SSE.
  • Stage content until turn completion, redact split deltas and classify criteria even when no records match; protect assistant logs with a scoped core redactor.
  • Reject proposals whose parameters were redacted; prevent confirmation before turn completion.
  • Read core table-level classification metadata and add secured-data canary tests for storage, audits, logs and in-memory model access.
  • Preserve durable-turn watchdog, supersede, shutdown and restart recovery with turn-scoped transcript redaction; widen token-bearing columns and entity limits, with H2-safe upgrade SQL.
  • Require QQQ 4.1.0-RC.7 (qqq#1182); retain the default audit-history opt-in and avoid duplicated routing headers against current core. RC.7 is released.

Unreleased - Foreign keys

  • Reference DDL: add the missing foreign keys (conversation tree, usage, data audit, budget accounting, profile skills) with orphan quarantine and their indexes; make assistant_model.provider_id ON DELETE CASCADE. A Postgres guardrail test fails any declared relation without a foreign key.

Unreleased - Durable turns

  • Renew user-owned run leases with heartbeats, poll cross-replica Stop, enforce hard deadlines, and finalize active runs during graceful shutdown. Recover abandoned runs on replay, active discovery, or the next question.
  • Default to superseding a prior answer in the same conversation. Accept explicit concurrent questions; the per-user cap supersedes the oldest run instead of refusing another question. Per-minute limits and budgets still apply.
  • Add GET /qqq/v1/assistant/turns/active, turn.phase progress and startedAt on message.start. Cancel is idempotent and returns HTTP 200.
  • Add the optional AssistantRunTracker Jobs tray SPI (no-op by default), pending QQQ core job tracking (#1210).
  • Add the durable-turn schema upgrade; apply it before deploying this runtime. A partial unique index fences duplicate terminal events from concurrent reapers.
  • UI API extension assistant 1.2.0 (additive): turns/active, the optional concurrency on a new turn, 503 SHUTTING_DOWN, turn.phase, message.start.startedAt, cancelReason SUPERSEDED. The 429 response stays documented for budgets and per-minute limits; the "answer already in progress" refusal is no longer returned. Cancel keeps 202 documented for older replicas.

Unreleased - Starter defaults

  • Every seed changeset's rollback is empty: a seeded row cannot be told apart from a host's own row with the same name, and Liquibase also rolls back changesets it marked ran, so a name-based undo would delete host data.

  • Fix the defaults changelog: every precondition is a portable sql-check (the formatted-SQL table-exists form failed to parse); the policy, budget and prices are seeded only into empty tables and the profile only when no default exists, so an existing host never changes on deploy. The test now runs the changelog through Liquibase 4.33, including an already configured host and a missing table.

  • The Setup card prices the default model the way calls are costed (registry, price rows, configured prices); "Add rows for every table" reads fresh data-access rows.

  • Ship db/liquibase/qbit-assistant/assistant-defaults.changelog.sql: insert-only starter rows for the policy, a default profile, four starter skills offered through it, a monthly instance budget (warn, downgrade, stop), current Anthropic prices and a disabled example scheduled task.

  • Add the Setup card to the Assistant home: every configuration table with its state (set up, needs setup, using defaults, optional) and the next step.

  • Add the assistant_data_access table action "Add rows for every table", which writes the default access out as editable rows.

  • Add help/qbit-assistant.yaml (QQQ help-bundle format) for the app and every assistant table, saying what each table is for and how its rows get created; applied by AssistantHelp until core help bundles ship in a release.

  • Guardrail tests: every table is used at runtime, seeded or explained, and has empty-state help; the changelog seeds a fresh schema and the seeded rows change the assistant's behavior.

Unreleased - X1/Y1

  • Add default-off withRecordHistory(true) host opt-in: remove only audit* on RC.6, or invoke core #1193 audit-read/write-deny support when available. Preserve user permissions and all other channel denials; kof22 does not opt in.

  • Register explicit assistant-channel denials on every assistant table, process, report and widget, preserving core security patterns; fail runtime startup when an assistant object is exposed.

  • Bump the assistant UI API extension to 1.1.0 for optional owned-turn attribution on explain-access, and regenerate its OpenAPI contract.

  • Preserve turn attribution in knowledge discovery, RAG reads and explain-access calls; confirm actions retain the original turn and scheduled runs retain the task.

  • Build audit links with the presentation filter-link builder, filtering by record, table and channel, including deletes; widen audit link storage to TEXT. Document the host audit migration and the QBit column widening.

  • Verify the admin denial matrix through the real invoker and Next UI, startup failures, and confirmed-write human/channel attribution with decoded audit links.

Unreleased - Email

Changed

  • Switch scheduled-result email delivery to qbit-email 0.1.0-RC.2's published com.kingsrook.qbits.email.api package, preserving templates, categories, idempotency keys, linked records and status mapping.
  • Add optional qbit-email 0.1.0-RC.1 delivery of a scheduled task's result (AssistantEmail.sendScheduledResult): template assistant_scheduled_result, link-only secure message for results above PUBLIC using core's DataClassification, owner-recipient hook, opt-in and emailExternalApproved gates. Scheduled-task wiring remains a separate integration step.

Unreleased - Provider API extraction (#30)

  • BREAKING: move all 23 provider API types, including ModelPricing, to com.kingsrook.qbits.assistant.spi. Recompile consumers with the new imports; see UPGRADE.md. Enforce exclusive package ownership and API dependency boundaries with ArchUnit.

  • Extract provider SPI/value types and ModelPricing into qbit-assistant-provider-api, now exclusively in com.kingsrook.qbits.assistant.spi. Its only production dependency is Jackson annotations.

  • Move Anthropic, OpenAI, Bedrock and Vertex main dependencies to the API; retain runtime fixtures in test scope. Pgvector still needs runtime RAG interfaces.

  • Provider-specific validation now takes unresolved ProviderSettings instead of AssistantProvider; add an ArchUnit boundary and standalone coverage gates.

Unreleased - P-g

  • Add admin Scheduled tasks, owner-locked run history, Quartz cron/timezones, and a 60-second QQQ scheduled process using the host's scheduler context.
  • Log in through the loopback UI API with a separately allow-listed credential environment reference; verify the designated user before starting a normal turn.
  • Add Review/Result Approve write mode, Revoke write mode and Run now actions. Approval binds the task definition; scheduled writes always await confirmation.
  • Attribute scheduled UI API requests and tool logs, apply per-run STOP caps, and send optional Alerts notifications to the designated user. HIPAA summaries contain only see conversation.
  • Add unique schedule-slot claims, fixed-clock fixture integration tests and additive Postgres DDL. No API routes or provider modules change.

Unreleased - P-f

  • Add OpenAI-compatible embeddings (including Azure), explicit CHAT/EMBEDDING model kinds, editable discovery hints, and budgeted EMBED usage.
  • Add admin Knowledge sources with streamed Review/Result Index now and Clear index actions. Indexing uses the clicking admin's UI API session, chunks text, skips unchanged hashes and reconciles disappeared records.
  • Add content-free in-memory and optional JDBC pgvector stores, per-instance schemas, HNSW cosine search and transactional record replacement.
  • Add permission-filtered search_knowledge, fresh UI API reads, existing masking/redaction, PHI embedding guards, classification propagation and citations.
  • Add embedding HTTP contracts, real UI API RAG security/golden tests and always-on JDBC contracts; the pgvector container test skips without Docker.

Unreleased - Answer presentation

  • Add present_results: server-formatted metrics, tables, computed percentages, charts and validated in-app filter/record/action links.
  • Persist and stream presentation parts with a multi-line GFM fallback and record-data retention; add a static default presentation skill.
  • Add the owner's 81 JPEG / 20 PNG golden example, shared live eval coverage, permission/formatting/input tests and strict-schema budget coverage.

Unreleased - P-e

  • Add per-profile heuristic/classifier model routing, with budgeted ROUTE usage.
  • Add bounded cross-provider fallback chains, PHI-safe selection, neutral history rebuilding, and a 60-second connection circuit breaker with cached local probes.
  • Show provider health, fallback totals and turn routing; include additive schema migration and admin record-screen controls.

Unreleased - P-d

  • Require QQQ RC.5 and send budget alerts directly through core QAlerts, with admin-visible alert type metadata and core sink tests.

  • Admin budgets, expiring overrides, deduplicated warning/downgrade/stop events.

  • Per-dispatch limits preserving PHI routing and existing caps.

  • Home budget cards, 30-day cost/token bars and provider breakdowns.

Unreleased — P-c

  • Add configurable table/field classification, persistent turn/conversation maxima, and PHI routing checks before every model call, with a safe blocked-turn reason.
  • Add HIPAA mode, eligible-provider enforcement, AES-256-GCM conversation storage, provider diagnostic redaction, and independent PHI conversation retention.
  • Add owner-locked, admin read-only data routing audits and HIPAA status on the home.
  • Classification levels are core's DataClassification (the qbit stand-in is removed); the default source reads core AI metadata merged with configuration.

All notable changes to this project are documented here. The format follows Keep a Changelog, and versions follow semantic versioning.

[0.1.0] - Unreleased

  • Add optional AWS Bedrock (Claude) and Google Vertex (Claude/Gemini) adapters using server credentials, static model discovery and allow-listed endpoint overrides. AWS IRSA/web-identity hosts supply STS themselves; no additional dependency is bundled.

  • Add the provider cloudProject column, cloud settings validation, injected Anthropic clients and per-request OpenAI bearer-token suppliers. Vertex Gemini prefixes model ids and omits strict tools.

  • Add localhost cloud contract tests and golden tool turns, including AWS binary event-stream framing, SigV4, token refresh and cloud HTTP error mapping.

  • Add named, server-only CA bundles for HTTPS providers, additive JVM trust and sanitized TLS diagnostics; tolerate repeated/missing streamed tool identities and invalid arguments, with a golden tool round-trip test.

  • Add the provider/model registry, per-model capabilities and prices, keyless local providers, discovery and health, and provider/model connection tests.

  • Add the optional JDK HTTP OpenAI-compatible adapter for OpenAI, Azure, OpenRouter, Ollama, LM Studio, vLLM and llama.cpp, with strict-schema safeguards and localhost contract tests.

Phase 0/1 of the QQQ Assistant: read-only Q&A and navigation. Requires QQQ 4.1 with:

Changed (0.1.0-RC.7, needs QQQ with QRun-IO/qqq#1128)

  • The assistant's endpoints are spec'd /qqq/v1 operations (UI API extension assistant 1.0.0, prefix /assistant): same URLs, bodies and statuses, now in the served OpenAPI; turns and events are text/event-stream. The raw routes are gone.
  • Tools call core's UiApiInvoker with the caller's own request credentials (UiApiCredentials); the loopback stand-in is removed. The assistant's own operations are never invocable by its tools.
  • The OpenAPI fragment is committed (openapi/qqq-v1/assistant.yaml) and contract-tested.

Changed (0.1.0-RC.6): tools go through the UI API (owner decision 2026-10-13)

  • Every data tool runs a spec'd /qqq/v1 operation with the user's own session (UiApiGateway, loopback HTTP with the sessionUUID cookie): the same authentication, permissions, record locks, validation, audit and errors as the UI. A user without a permission gets the identical denial through the assistant. navigate and search_help (no records) stay on the core kernel. LoopbackUiApiGateway is a stand-in for core's UiApiInvoker (QRun-IO/qqq#1128).
  • Tools: list_tables, describe_table, query_records, count_records, get_record, search_records, render_widget, get_record_history, and new create_record, update_record, delete_record.

Added (0.1.0-RC.6)

  • Profiles (assistant_profile, nav "Profiles"): display name, icon, personality (≤ 2,000 chars, appended to the per-user system block), greeting, starter prompts; one default, optional app / permission scope. /assistant/config returns profile.
  • Skills (assistant_skill, nav "Skills", linked through assistant_profile_skill, a child list on profiles): admin-written Markdown instructions read only when relevant (keywords / apps), ≤ 6,000 chars each and 12,000 per question. Instructions only: they never grant tools, endpoints or access (ArchUnit-enforced).
  • Explain access: GET /qqq/v1/assistant/access?table=&prompt= and the assistantAccess home widget: per table and tool, allowed or the reason.
  • Recent denials card on the Assistant home.
  • AssistantStore refuses any table that is not assistant_*; ArchUnit forbids the tool / UI API / access packages from using QQQ actions, the AI kernel or the profile and skill classes.
  • Data access (assistant_data_access, nav table "Data access"): NONE / READ / READ_WRITE per table or process. Default: NONE for sensitive tables (AI exposure NONE; cannot be raised), READ for other tables, NONE for processes. It only narrows the UI API.
  • Pending actions (assistant_pending_action): with confirmation on (the default), a write only proposes; POST /qqq/v1/assistant/actions/{id}/confirm runs it through the UI API with the confirming user's session, once; .../reject; GET .../actions/{id}. Child list on conversations.
  • Policy columns on assistant_policy: writesRequireConfirmation, allowedTools, maxRowsPerCall, redactPii.
  • Tool-call log columns on assistant_tool_call: uiApiOperation, httpStatus, auditHref, pendingActionId; tool inputs are stored with secrets redacted.
  • Config: uiApiBaseUrl, withUiApiGateway, pendingActionTtlSeconds. /assistant/config reports features.writes (some table is READ_WRITE) and features.pendingActions.

Fixed (0.1.0-RC.5)

  • Every turn failed with "The AI service could not answer this request." Anthropic refused the request with 400 invalid_request_error "The compiled grammar is too large ... reduce the number of strict tools": all ten read tools were sent with strict: true, and the API compiles every strict schema of a request into one grammar. Tools are now not strict by default. The kernel already validates every tool input and answers INVALID_INPUT. A host can opt individual tools in with withStrictToolNames(...).
  • If a provider still refuses the strict schemas, the turn retries once without strict tools and logs a warning (ModelProviderException.isStrictSchemaRejected).
  • Provider errors now carry the API's own error type, a sanitized message (one line, capped, key-like tokens redacted) and the request id:
    • they are logged at WARN for 4xx;
    • they are recorded in the assistant_usage row's request id;
    • they are shown to assistant.admin holders in the panel and in Test connection. Before this, a 400 was logged as "INVALID_REQUEST httpStatus=400" with no reason.
  • A turn that hits a JVM Error (for example a linkage problem) now ends as ERROR with a log line, instead of being swallowed by the executor and ending as an empty COMPLETE answer.
  • AnthropicRequestContractTest checks the exact request against the Messages API constraints and a conservative strict-tool budget.

Added (0.1.0-RC.4)

  • Provider settings (assistant_provider): type (registered AssistantProviderTypes; AnthropicProviderType in the Anthropic module), model, enabled, default, base URL override, monthly cost cap, max output tokens per request, max concurrent requests, and the last connection test. The default enabled row is the active provider; with no rows the provider registered in code is used, so existing hosts keep working.
  • Key handling: the key is never stored. A row names the environment variable that holds it (apiKeyEnvName, allow-listed); apiKeyStatus is computed on read (Set • …abcd) and dropped on save. A pasted key, an unrelated variable or an unlisted base URL is refused. New config: withProviderType, allowedApiKeyEnvNames, allowedBaseUrls, withSecretResolver, providerTestTimeoutSeconds.
  • Test connection, a record action on providers (standard streamed ETL Review and Result lines): one short live call, result saved on the row and recorded as a TEST usage row.
  • Per-call usage (assistant_usage): one row per model call (turn steps and tests) with provider, model, status, error kind, HTTP status, latency, tokens and cost. Child list on conversations and providers.
  • Model prices (assistant_model_price), editable, used for every call's cost; the reference DDL seeds the Anthropic list prices of 2026-10. The built-in pricing defaults now cover every current Claude model.
  • The app home (assistantHome widget): health, providers, usage by day, costs against caps, by user, top tools and recent failures, from real rows with empty states.
  • ModelResponse.model: the model that answered (set by the Anthropic adapter).

Changed (0.1.0-RC.4)

  • The app is labelled Assistant (name assistantAdmin unchanged). Its nav lists the home and the main tables only (Providers, Conversations, Model prices, Policy); other tables are child lists. assistantRetention is a table action on conversations instead of a nav entry.
  • Every assistant_* table has AI exposure NONE, so the assistant never reads its own settings or audit tables (before, only the assistant.admin permission kept them out).
  • AssistantMetaDataBuilder.buildAdminApp(tables) no longer takes the retention process.

Changed (0.1.0-RC.3)

  • Snake_case names (QRun-IO naming rule, 2026-10-12). Metadata only; the physical tables were already assistant_*:
    • tables assistantConversation ... assistantUsageDaily are now assistant_conversation, assistant_message, assistant_turn, assistant_turn_event, assistant_tool_call, assistant_feedback, assistant_policy and assistant_usage_daily; the conversation possible-value source follows the table name
    • permissions are unchanged: assistant.hasAccess keeps QQQ's standard hasAccess suffix, and assistant.admin stays as is
    • security key type assistantUserId is now assistant_user_id, with all-access key assistant_user_id_all_access
    • the old names remain as @Deprecated LEGACY_* constants until 0.2.0. See UPGRADE.md for the stored references to update (permissions, saved views and reports, audit_table names).

Added

  • qbit-assistant
    • AssistantQBitProducer and AssistantQBitConfig:
      • enable flag, plus the QQQ_ASSISTANT_ENABLED environment variable
      • provider name, model routing (Sonnet 5.5 at effort medium)
      • limits: 25 tool calls, 4 parallel, 50 rows, 500 characters per cell, 300 s
      • budgets, rate limits
      • retention of 90 / 30 / 365 days
      • owner key type and pricing.
    • Standard, visible tables: assistant_conversation, assistant_message, assistant_turn, assistant_turn_event, assistant_tool_call, assistant_feedback, assistant_policy and assistant_usage_daily. User-owned tables are locked to their owner (assistant_user_id), and admins read everything through assistant_user_id_all_access.
    • The Assistant Admin app, gated by assistant.admin.
    • The assistantRetention process.
    • /qqq/v1/assistant routes: config probe, conversations CRUD, turns as Server-Sent Events, replay through /events?after=, cancel, and feedback. The panel requires assistant.hasAccess. The kill switch and budgets are checked when a turn starts, and the kill switch again before every step.
    • TurnEngine:
      • streams the model's answer
      • runs read and navigation tools through AiToolKernel, with parallel calls bounded
      • sends results back in one message, with errors marked is_error
      • enforces the limits on tool calls, time, budget, cancel and kill switch
      • persists messages, turns, tool calls, events and usage.
    • ContextBuilder in cache-prefix order: tools, then the static system block (1 h), then the user digest (5 m), then history, then the new turn.
    • The provider SPI (AssistantModelProvider, ModelRequest, StreamHandle, ModelCapabilities) and ScriptedModelProvider.
    • The record Developer-screen "Assistant" panel, built on the core developer-panel contract, with the "Ask the assistant about this record" deep link.
  • qbit-assistant-provider-anthropic: AnthropicModelProvider on anthropic-java 2.71.0. It provides:
    • strict tools and tool_choice: auto
    • adaptive thinking with display omitted, and an explicit effort
    • cache breakpoints (1 h / 5 m)
    • refusal and max_tokens handling
    • cancel
    • an API key taken only from the environment or a host secret.
  • Tests:
    • ArchUnit rules
    • golden evals v1: examples D and the read half of A, plus count, navigate and search
    • the permission red team (a build gate)
    • Anthropic tests against recorded SSE streams
    • live evals against Claude (opt-in).

Security review follow-ups

  • Kernel settings come from config: allowedWidgetNames (the kernel denies any widget not listed), hiddenWidgetNames and customRedactionPatterns.
  • Orphaned turns. A turn left RUNNING by a dead replica is finished as ERROR the first time it is seen, by replay, cancel, delete or a new turn. Its message gets status ERROR, and synthetic error (INTERNAL) and message.end events are persisted. Replay loops are capped at maxTurnSeconds + 60 s.
  • Retention. The tool-result cutoff now also deletes stream events, and removes render payloads and record references from stored messages.
  • Usage. addUsage uses per-key locks, a unique key on (scope, userId, usageDate) and an insert-race retry. Usage of cancelled, timed-out and failed model calls is counted when the provider reports it.
  • Kill switch. It fails closed when the policy can't be read. A watcher cancels running turns promptly when it flips.
  • Limits. Page-context scalars are capped (strings up to 512 characters, at most 100 selected ids). Conversation lists are limited to 200, running turns to 50, and replay is paged at 500 events.
  • Ordering. Terminal events (usage, error, message.end) are persisted before the turn's status becomes final.
  • Supplemental meta-data. The assistant supplemental instance meta-data is registered when the QBit is enabled.

[0.1.0-RC.2] - 2026-10-12

Do not use. 0.1.0-RC.2 on Maven Central is an accidental republish of the RC.1 code by a tag build; do not use it. The next real candidate is 0.1.0-RC.3. (Cause: QRun-IO/qqq-orb#20; the publish_release workflow now runs on GA tags only.)