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

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/assistant routes, 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/qqq docs/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_access row 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 by tools/process/ProcessRunnability; a host notices drift between its list and its application in its own build with RunnableProcessReview.
  • 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 and ModelPricing, all exclusively in com.kingsrook.qbits.assistant.spi; only Jackson annotations in production. Provider validation takes unresolved ProviderSettings, not an entity.

  • qbit-assistant (com.kingsrook.qbits.assistant):

    • AssistantQBitProducer, AssistantQBitConfig (with AssistantModelRouting and AssistantBudgets), 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 the assistant.admin custom permission checker.
    • home/: the app home widget (AssistantHomeData loads, AssistantHomeView renders flow-layout HTML).
    • model/: entities for the assistant_* 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, the TurnEvent / TurnEventLog stream, and MessageParts.
    • api/: AssistantApi (transport-free) and AssistantRouteProvider (Javalin, SSE).
    • provider/: runtime-backed RegistryModelProvider, plus fake/ScriptedModelProvider for tests, evals and demos. The SPI lives in qbit-assistant-provider-api.
    • developer/: the record Developer-screen panel, on the core developer-panel contract.
    • processes/: AssistantRetentionStep and TestConnectionProcess (record action on assistant_provider).
    • engine/ProviderService (active provider from assistant_provider rows, key references, prices), UsageRecorder (one assistant_usage row per model call), ConnectionTest.
  • qbit-assistant-provider-anthropic: AnthropicModelProvider and AnthropicMapping (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 QSystemUserSession or construct a QSession in main code (ArchUnit).
    • Every route uses ExecutorSessionUtils.setupSession (the user's own session).
    • AssistantSecurity only ever adds the user's OWN id key, or the all-access key for assistant.admin holders.
  • Channel wall: every assistant_* object (table, process, report and widget) is denied to the assistant channel; startup fails otherwise. Requests carry assistant/<turnId> or assistant/scheduled/<taskId>; administration uses next-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 registered ToolFamily: the QBit's own are the explicit list AssistantRuntime.ownToolFamilies(), a host's or another QBit's come from AssistantQBitConfig.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/, only engine/ToolCallService runs a tool or writes an assistant_tool_call row (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.
    • ToolCallService trusts no caller and takes nothing it can find out itself. A caller hands it a tools.registry.ToolCall (a SignedInPerson, 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 tool Context with 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, ConfirmedToolCall and UiApiToolbox.Context are final classes with no public constructor, made only through of(ToolCallService.Permit, ...); the permit has a private constructor and the service never hands its own out. AssistantRuntime has 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) and cancel(person, pendingActionId), called only by AssistantApi (ArchUnit); offers(person, toolName) and offersAny(person, toolNames); readContext(person, timeout) (only AssistantApi and RagService, ArchUnit); expiredIfDue(userId, action); failAbandonedActions(now) for the retention process. execute(call, scope), log(call, scope, outcome) and withdraw(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.
    • SignedInPerson binds a session to the one credential forwarded to the UI API. It has no public constructor: ofRequest(Context) (only AssistantSpecs, ArchUnit; it forwards only the credential the session was resolved from) or ofSession(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, and UiApiToolbox.read, the four read-only tools the starter prompts and the knowledge index use).
    • UiApiToolbox.read needs a Context, 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) and forReadWhateverTheToolSwitches() 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/v1 operations with the user's sessionUUID); never core data actions for user data. Only navigate and search_help use AiToolKernel.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's assistant_data_access row narrows it and never raises it. A table whose AI exposure is NONE stays NONE, and so does every assistant_* 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, within toolCallTimeoutSeconds; 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 why assistant-data-access-follow-user.changelog.sql exists.
    • 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.writesRequireConfirmation is no longer read). ToolCallService.confirm loads 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; expirePendingAction is a claim with the decision EXPIRED), which inserts the action's row into assistant_pending_action_claim (unique pendingActionId). Never decide an action with a status update alone, never expire one from a copy read earlier or a time a caller names (ToolCallService.expiredIfDue decides from the stored action and the runtime's clock), and never drop that unique key.
    • What was approved is what is written: update_record and delete_record read the record as the person when they propose and keep its version (a digest of the whole record, never values; beforeJson holds 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 than approvalRetentionDays, 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 a process_run card. list_processes and describe_process answer 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 the assistant channel (withDeniedClients, ClientChannelsMetaData, core's patterns), the assistant's own, data access not READ_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_process only 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 fixed RESERVED set, which ProcessControlNamesTest checks 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 most MAX_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 the assistant channel; do not route around that (no other channel, no in-process call). A process that may run as a job is never run through init instead: 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_status reads 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.
    • RunnableProcessReview is how a host notices drift in its build; it decides nothing at runtime. It evaluates ProcessRunnability with 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_error results.
    • Unknown tools and over-limit calls also get error results.
  • Keys: never stored in a table. assistant_provider.apiKeyEnvName holds only an allowed environment variable NAME; apiKeyStatus is computed on read (last 4 at most) and stripped on save.

    • Scheduled task credentialEnvName is a separately allow-listed environment variable NAME. Tokens never enter rows, model context or logs. credentialStatus is 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.
  • 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 /app or /app/... hrefs are emitted (TurnEngine.isAppHref).

  • PHI routing: PHI only to hipaaEligible providers; 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.admin only, a fixed prompt, no tools, PING_MAX_TOKENS output tokens, a TEST usage 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 holds TurnRunner, GoldenTasks and EvalSupport. The testing package, and nothing else, is published as a test-jar for the provider modules' tests (TestJarContentsTest). RuntimeInternals, ToolCallServiceInternals and Toolboxes reach behind the tool-call service for this module's own tests: keep them, and anything like them, out of testing/.
  • 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) and redteam (RedTeamTest) run in every build. Any failure fails the build.
    • live is excluded unless you pass -Dtest.excludedGroups= -Dgroups=live and 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 withX setters
  • wrapper types.

Release

  • CircleCI qqq-orb 0.6.8.
  • develop publishes 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}.