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>
23 KiB
CLAUDE.md
This file provides guidance to Claude Code when working with code in this repository.
Project Overview
QBit Assistant adds an in-app AI assistant to any QQQ application. It owns the chat backend:
- conversations and audit tables
- the turn engine (the agent loop)
- the
/qqq/v1/assistantroutes, with SSE - budgets, rate limits and the kill switch
- the model-provider SPI.
The panel UI lives in qqq-frontend-next. Data access goes only through the QQQ UI API (/qqq/v1, spec'd operations, the user's own session) via uiapi/UiApiGateway and tools/UiApiToolbox; only navigate and search_help (no records) use the core AI tool kernel.
- Design spec:
QRun-IO/qqqdocs/specs/2026-10-11-qqq-assistant-qbit-design.md. The owner decisions D1 to D6 at its top are binding. - Writes (create/update/delete) follow the signed-in person: the assistant may propose any change that person may make themselves, with no settings row (the default, READ_WRITE, is declared in code; an
assistant_data_accessrow can only narrow it). A write always waits for the user's confirmation (pending action card); nothing switches that off. - Processes follow the signed-in person too: the assistant may run a process that person may run themselves (
tools/process), after they confirmed the run on a card, when the host named it on its reviewed list (AssistantQBitConfig.withReviewedProcesses); with no list, only a table's own bulk edit and bulk delete. What is never runnable is decided bytools/process/ProcessRunnability; a host notices drift between its list and its application in its own build withRunnableProcessReview. - The assistant answers nobody for whom no spending limit is in force (
BudgetService.limitInForce): fail closed, until an administrator restores a limit.
Build Commands
./mvnw verify # all modules, tests, JaCoCo gate (70% instructions / 90% classes)
./mvnw verify -Dqqq.snapshot.version=4.1.0-ASSISTDEV-SNAPSHOT # against a locally installed QQQ build with the kernel (+ developer panels)
./mvnw test -pl qbit-assistant -Dtest=RedTeamTest
ANTHROPIC_API_KEY=... ./mvnw verify -Dassistant.live=true -Dtest.excludedGroups= -Dgroups=live -Djacoco.skip=true -pl qbit-assistant-provider-anthropic -am
Always use the wrapper: Maven 3.10 cannot read the parent POM. The committed default QQQ version (qqq.snapshot.version in the root POM) must stay resolvable from Maven Central. Use a local kernel build only through -D.
Modules
-
qbit-assistant-provider-api: standalone provider SPI/value types andModelPricing, all exclusively incom.kingsrook.qbits.assistant.spi; only Jackson annotations in production. Provider validation takes unresolvedProviderSettings, not an entity. -
qbit-assistant(com.kingsrook.qbits.assistant):AssistantQBitProducer,AssistantQBitConfig(withAssistantModelRoutingandAssistantBudgets),AssistantRuntime,AssistantSecurity.metadata/: tables, owner key type, the Assistant app (home + main tables in the nav), the retention process, the settings customizer (key-reference checks, masked key status), and theassistant.admincustom permission checker.home/: the app home widget (AssistantHomeDataloads,AssistantHomeViewrenders flow-layout HTML).model/: entities for theassistant_*tables.persistence/AssistantStore: the only code that calls QQQ data actions, and only on the QBit's own tables, under the user's session.engine/:TurnEngine(the loop),ContextBuilder(cache-prefix order),SystemPrompt,PolicyService(policy row, budgets),RateLimiter, theTurnEvent/TurnEventLogstream, andMessageParts.api/:AssistantApi(transport-free) andAssistantRouteProvider(Javalin, SSE).provider/: runtime-backedRegistryModelProvider, plusfake/ScriptedModelProviderfor tests, evals and demos. The SPI lives inqbit-assistant-provider-api.developer/: the record Developer-screen panel, on the core developer-panel contract.processes/:AssistantRetentionStepandTestConnectionProcess(record action onassistant_provider).engine/ProviderService(active provider fromassistant_providerrows, key references, prices),UsageRecorder(oneassistant_usagerow per model call),ConnectionTest.
-
qbit-assistant-provider-anthropic:AnthropicModelProviderandAnthropicMapping(anthropic-java streaming). -
qbit-assistant-provider-openai: OpenAI-compatible Chat Completions, model discovery and health using JDK HTTP and Jackson. -
qbit-assistant-provider-bedrock: Claude through the Anthropic Bedrock backend, AWS default credentials and SigV4; IRSA/web-identity hosts supply STS. -
qbit-assistant-provider-vertex: Claude through the Anthropic Vertex backend and Gemini through OpenAI-compatible Chat Completions, with refreshed Google ADC tokens. -
qbit-assistant-rag-pgvector: optional host-supplied JDBC vector store, isolated by instance schema; depends on runtime RAG interfaces (VectorStore,ChunkVector), with no QQQ RDBMS internals.
Invariants (do not break)
-
Transcript redaction: stored transcripts never contain raw PII/PHI values; tokens via
ClassifiedValueTokens. Redact storage copies before truncation/encryption; eligible model inputs and live SSE remain unchanged. -
Sessions:
- Scheduled runs log in as the designated user through
POST /qqq/v1/manageSession; resolve only its returned UUID, verify the user id, and never construct or elevate a session. The QQQ-supplied scheduler context touches only assistant bookkeeping. - Never use
QSystemUserSessionor construct aQSessionin main code (ArchUnit). - Every route uses
ExecutorSessionUtils.setupSession(the user's own session). AssistantSecurityonly ever adds the user's OWN id key, or the all-access key forassistant.adminholders.
- Scheduled runs log in as the designated user through
-
Channel wall: every
assistant_*object (table, process, report and widget) is denied to theassistantchannel; startup fails otherwise. Requests carryassistant/<turnId>orassistant/scheduled/<taskId>; administration usesnext-ui. -
Data access:
- The vector index stores no record content: only source/table/key/field/chunk, content hash and embedding. Retrieval re-reads every hit through the UI API as the asking user, then applies the existing get_record exposure/masking path.
- Only
persistence/calls QueryAction, InsertAction and the other data actions (ArchUnit). - Every tool is registered once in
tools/registry/ToolRegistry(name, kind, checks, definition, execute) by a registeredToolFamily: the QBit's own are the explicit listAssistantRuntime.ownToolFamilies(), a host's or another QBit's come fromAssistantQBitConfig.withToolFamily. Nothing is found on the classpath. A new family of the QBit is new files and one entry in that list. - A tool is looked up only in the registry. Outside
tools/, onlyengine/ToolCallServiceruns a tool or writes anassistant_tool_callrow (ArchUnit); the turn engine and the confirm route both go through it. A 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. ToolCallServicetrusts no caller and takes nothing it can find out itself. A caller hands it atools.registry.ToolCall(aSignedInPerson, the call's id, name and input) and nothing else: the service reads the policy fresh on every call (PolicyService.current(true), narrowed for a scheduled task's conversation; never the cache), builds the toolContextwith the gateway the runtime handed it and keeps the proposed writes itself.- The boundary holds at compile time for code outside the assistant's packages, not only by ArchUnit. Keep it so:
ToolCallRequest,ConfirmedToolCallandUiApiToolbox.Contextare final classes with no public constructor, made only throughof(ToolCallService.Permit, ...); the permit has a private constructor and the service never hands its own out.AssistantRuntimehas no public getter for the tool registry, the UI API gateway, a toolbox or its tool families: it hands them to the collaborators it builds. Never add a public way to any of them, and never a public method on the service that runs a tool without writing its row. - Its public entry points:
executeAndLog(call, conversationId, hipaaRoute)for a caller with no turn (the service makes the scope);confirm(person, pendingActionId)andcancel(person, pendingActionId), called only byAssistantApi(ArchUnit);offers(person, toolName)andoffersAny(person, toolNames);readContext(person, timeout)(onlyAssistantApiandRagService, ArchUnit);expiredIfDue(userId, action);failAbandonedActions(now)for the retention process.execute(call, scope),log(call, scope, outcome)andwithdraw(call, scope)are package-private, for the turn engine only. - On every call, before the tool is reached, it checks: a signed-in person, assistant access, the kill switch, that the scope's conversation and turn are that person's, the policy (unreadable refuses), the hard budget (that a spending limit is in force at all, then the limits; read on every call, never remembered per scope; one aggregate query per budget through
AssistantStore.sumUsage, never the usage rows) and that the tool is offered to this person now. What a caller still supplies (rate limiting, per-request call limits and timeouts, classification, the HIPAA route) is listed in the service's javadoc. SignedInPersonbinds a session to the one credential forwarded to the UI API. It has no public constructor:ofRequest(Context)(onlyAssistantSpecs, ArchUnit; it forwards only the credential the session was resolved from) orofSession(qInstance, session)(the session's own id is the credential: the authentication module resolves it, it must be the user the session names, and the person IS the session the module resolved, never the object passed in: only its time zone is carried over). Never make a person from a session that was not checked, never read access, the owner key or a permission from a session object a caller handed over, never pass a session and credentials separately, and never let the turn engine make a person.- The toolboxes cannot be called around the service: their entry points that run a tool are package-private, the runtime has no getter for a toolbox (each family is handed its own), and no class outside
tools/calls into it except through the registry (ArchUnit, with a short allow-list: the runtime's wiring, the value types, andUiApiToolbox.read, the four read-only tools the starter prompts and the knowledge index use). UiApiToolbox.readneeds aContext, and every context is made by the service:ToolCallService.readContext(person, timeout)for a read outside a tool call (it checks the person, assistant access and the kill switch, reads the policy itself and uses the runtime's gateway), or the context of the tool call the read is part of.Context.forRead(timeout)andforReadWhateverTheToolSwitches()only derive a narrower one. Never add a public way to make a context from a session id, a gateway or a policy. These own reads have no tool-call log row (nothing of them reaches a model; an index run has its process record) and do not ask the hard budget (they call no model).- The turn engine writes one row per started call in a per-call
finally: a call that ran past its time limit, one whose result could not be processed, and a batch the engine was interrupted in all keep their rows, of the real outcome when the worker finished. A row that cannot be written for a call that changed data is logged at ERROR. - A missing policy refuses; it never means "every tool".
- Data tools run only through
UiApiToolbox→UiApiGateway(spec'd/qqq/v1operations with the user'ssessionUUID); never core data actions for user data. Onlynavigateandsearch_helpuseAiToolKernel.execute. - Data access (
DataAccessService) and the policy only ever narrow what the UI API allows. The mode of a table or process is declared in versioned code: the default (withDefaultTableAccess/withDefaultProcessAccess, READ_WRITE) or a pin (withTableAccess/withProcessAccess). An administrator'sassistant_data_accessrow narrows it and never raises it. A table whose AI exposure is NONE stays NONE, and so does everyassistant_*table and every process of the QBit (DataAccessService.isOwnTable: a pin in code that no row and no host pin lifts). Rows that cannot be read and were never read mean NONE for everything, never the default. - READ_WRITE grants nothing: the write tools are offered to a person only when the UI API says that person may create, edit or delete records of some table (
UiApiToolFamily.writableTables, asked as the person, once per offer, withintoolCallTimeoutSeconds; no answer means no write tool). The person's permission for the change itself is decided by the UI API when the confirmed change runs, with its own message. Never materialise defaults as rows again: the retired "Add rows for every table" action is whyassistant-data-access-follow-user.changelog.sqlexists. - A write runs only after the owner confirms its pending action, with the confirming request's session, once; no policy or caller can turn confirmation off (
assistant_policy.writesRequireConfirmationis no longer read).ToolCallService.confirmloads the stored action for that person, claims it, runs it and writes its row; whatever fails after the claim ends in FAILED with a row, never in RUNNING. - A pending action is decided once: confirming, cancelling and expiring all go through
AssistantStore.decidePendingAction(final;expirePendingActionis a claim with the decision EXPIRED), which inserts the action's row intoassistant_pending_action_claim(uniquependingActionId). Never decide an action with a status update alone, never expire one from a copy read earlier or a time a caller names (ToolCallService.expiredIfDuedecides from the stored action and the runtime's clock), and never drop that unique key. - What was approved is what is written:
update_recordanddelete_recordread the record as the person when they propose and keep its version (a digest of the whole record, never values;beforeJsonholds only what the card showed, after the exposure rules of get_record). A confirmed update or delete reads the record again and refuses when it changed, is gone or cannot be read. A proposal without a version is refused, not run. - A tool call whose own outcome the turn engine did not take (timed out, stopped, failed, unprocessable) shows no card, so the engine has the service withdraw what it proposed; a proposal made after that is refused.
- Retention first ends pending actions left RUNNING for more than
ToolCallService.ABANDONED_AFTER(one hour) as FAILED, with "outcome unknown, check the record" and a tool-call log row, then deletes decided and lapsed pending actions older thanapprovalRetentionDays, with or without a conversation. No action stays RUNNING for ever. - Process runs (
tools/process): the assistant runs a process the signed-in person may run themselves, as that person, after they confirmed aprocess_runcard.list_processesanddescribe_processanswer from the person's own metadata (asked as them through the UI API): 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. Never answer either from the instance's metadata alone. - What is never runnable through the assistant is decided in one place,
ProcessRunnability, which the tools and the host's build-time review share: denied to theassistantchannel (withDeniedClients,ClientChannelsMetaData, core's patterns), the assistant's own, data access notREAD_WRITE, hidden (except a table's bulk edit and bulk delete), offered by no screen (no app, no table), on a table the assistant may not change, not on the host's reviewed list (except those two bulk actions; no list means only those two), or with a screen the assistant cannot present. It only narrows. The reviewed list is read from versioned code (DataAccessService.reviewedProcesses); never let a row, a setting or a default add a process to it. run_processonly proposes: inputs are checked against the declared form fields before a card exists (unknown, wrong type, missing required), no value is ever invented, and the card shows the process, every input, what the first screen says, the records and the stops. An input is only a field a screen asks a person for: a field whose name the process keeps for its own working (ProcessControlNames: the inputs of its backend steps and the fixedRESERVEDset, whichProcessControlNamesTestchecks against the QQQ version built with) is never an input, at the proposal and again at the confirmation. The records of a run are fixed at the proposal by primary key (a filter is resolved then, at mostMAX_RECORD_IDS); the confirmed run is sent those keys, never a filter, and refuses when one is gone.- The assistant creates a durable job and never starts one: it calls only the launch operation, sends no
expectedRows(so core's assistant auto-start never applies), and calls no job control. Core refuses a start from theassistantchannel; do not route around that (no other channel, no in-process call). A process that may run as a job is never run throughinitinstead: where the launch operation is not spec'd, it is refused. - A confirmed run never keeps its pending action RUNNING for the run's duration: the action ends when the job is created or the run ended, stopped, failed or outlasted the wait (
ProcessTools.setWaitMillis).get_run_statusreads and never steps a run. A run stops at every screen that has something for the person which their card did not show (a review, a form, values to read, a text, a list of records); only a screen with nothing to show is passed (ProcessTools.advance). Going on is a new proposal and a new card that shows what the screen shows, as the server kept it (the in-memory run, keyed by a reference only its person can use), never what the model passed; the card carries the run's version and goes on only with the run as it stood then. - A run's result carries the process's 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, all redacted. Text of the application reaches a model only inside the data envelope (
AiDataEnvelope): in a result, in a card's lines, and in what a later request is told of a process step or a confirmed run (ToolLabels.forModel,MessageParts.modelText). Never concatenate such text into a tool's own words. RunnableProcessReviewis how a host notices drift in its build; it decides nothing at runtime. It evaluatesProcessRunnabilitywith the code's data access (DataAccessService.forProcessInCode/forTableInCode, no administrator's row) and compares the reviewed file with what could be listed and with the registered list. Keep it free of a server and a database.- Panel API queries also filter
userId = me, so an admin's all-access key never mixes other users' conversations into the panel.
-
Caching: the request prefix stays byte-stable. The order is tools (kernel-sorted), then system block 1 (rules + primer + app digest, LONG/1h), then system block 2 (user digest + date, SHORT/5m), then messages. Exactly one SHORT hint goes on the newest message. Don't put timestamps or ids in the system prompt.
-
Tool results:
- Parallel tool results go back in ONE user message, in call order.
- Errors (DENIED, NOT_FOUND, INVALID_INPUT, ERROR) are
is_errorresults. - Unknown tools and over-limit calls also get error results.
-
Keys: never stored in a table.
assistant_provider.apiKeyEnvNameholds only an allowed environment variable NAME;apiKeyStatusis computed on read (last 4 at most) and stripped on save.- Scheduled task
credentialEnvNameis a separately allow-listed environment variable NAME. Tokens never enter rows, model context or logs.credentialStatusis computed and stripped on save. - Scheduled write approval is invalidated by changes to prompt, run-as user, credential reference, profile or write request. Scheduled writes always require the designated user's panel confirmation.
- Scheduled task
-
Logging: never log message content, prompts, tool results or keys. Structured end-of-turn log only.
-
Thinking: thinking is never streamed or stored. The Anthropic adapter keeps the raw assistant message (with signatures) only in memory, as
providerState, for the tool loop. -
Links: only
/appor/app/...hrefs are emitted (TurnEngine.isAppHref). -
PHI routing: PHI only to
hipaaEligibleproviders; checked before every model call. Conversation classification never decreases. -
HIPAA storage: Conversation storage encrypted in HIPAA mode, only in
AssistantStore; encryption keys are environment references and never logged or stored in tables. -
Fallback: fallback never crosses the PHI rule; never local-to-cloud by default. No retry after partial output from that call; provider switches rebuild neutral history without providerState.
-
Budgets: budgets are checked before every model call; downgrade never crosses the PHI rule. No spending limit in force for the person (
BudgetService.limitInForce: a hard limit above zero in the code configuration or on the policy row, or an enabled budget row with a limit above zero that applies to them) refuses the turn before anything is stored, every later model call (the routing classifier's included, asked for the classifier's own model), every tool call and every embedding request. Never add a path that calls a model without that check. The one exemption, by decision: the administrator's Test connection (ConnectionTest:assistant.adminonly, a fixed prompt, no tools,PING_MAX_TOKENSoutput tokens, aTESTusage row). -
Kill switch: read fresh before every model call and every tool step.
Tests
testing/holds the fixture app on the memory backend:- blog, page, media and analytics tables, plus audits
- sentinel values in hidden and password fields
- a prompt-injection record
- MOCK auth where the user-id prefix picks the permissions.
testing/also holdsTurnRunner,GoldenTasksandEvalSupport. Thetestingpackage, and nothing else, is published as a test-jar for the provider modules' tests (TestJarContentsTest).RuntimeInternals,ToolCallServiceInternalsandToolboxesreach behind the tool-call service for this module's own tests: keep them, and anything like them, out oftesting/.- The fixture declares a shared monthly spending limit in code (the assistant refuses with none in force);
AssistantTestFixture.withoutSpendingLimit(config)takes it away for a test of the refusal.author-*users may change posts, pages and media;editor-*may only read. - Tags:
golden(GoldenEvalTest) andredteam(RedTeamTest) run in every build. Any failure fails the build.liveis excluded unless you pass-Dtest.excludedGroups= -Dgroups=liveand set the key.
Code Style
QQQ style:
- 3-space indent, braces on the next line,
return (x); - flower-box javadoc on every class and method
- fluent
withXsetters - wrapper types.
Release
- CircleCI qqq-orb 0.6.8.
developpublishes SNAPSHOTs.release/*publishes RCs; the orb bumps<revision>in the root POM.v*tags publish releases.- All seven modules and the parent POM publish. The flatten plugin resolves
${revision}.