Full reference: See
docs/root/CLAUDE.mdfor comprehensive architecture, all API endpoints, entity details, and integration flows. Architecture summary: SeeAGENTS.mdfor high-level codebase map used by all AI agents.
DBA Agent is an AI-powered Database Performance Assistant with autonomous troubleshooting capabilities. Monorepo with a Java backend and a React frontend.
Tech Stack:
- Backend: Spring Boot 4.0.3 (Java 25), Spring AI 2.0.0-M2, PostgreSQL vault DB
- Frontend: React 19.2.3, Vite 7.3.0, Tailwind CSS 4.1.18
- AI: bring-your-own LLM via
LlmProviderRegistry— OpenAI, Azure OpenAI, or any OpenAI-compatible server. Vector store: pgvector or Azure AI Search (RAG) - Caching: Redis/Valkey
- Databases supported: PostgreSQL, MySQL (via provider registry pattern)
cd backend
mvn clean install # Build
mvn spring-boot:run # Run (dev mode, auth disabled)
mvn spring-boot:run -Dspring-boot.run.profiles=prod # Run (prod, auth enabled)
mvn test # Run all tests
mvn test -Dtest="*IntegrationTest" # Integration tests onlyBackend URL: http://localhost:8080/api
npm install # Install dependencies
npm run dev # Dev server (http://localhost:3000)
npm run lint # Lint
npm run test:local-regression # Quick local regression suite
npm run local-deploy # Start services + run local regression suite
npm run mcp:phase1 # Start the Phase 1 DeepSQL MCP server (stdio)
npm run build # Build (dev)
npm run build:production # Build (prod)Dev credentials: There is no baked-in admin/admin login — AuthController.login requires a
real User row matched by email, not username, so a fresh database (new Postgres volume)
has no account to log in with at all. SECURITY_AUTH_ENABLED=false only bypasses JWT/MCP token
validation (JwtAuthenticationFilter, McpTokenAuthenticationFilter); it does not create a
user or skip the login form. Create the first admin via the bootstrap endpoint, gated by
SECURITY_ADMIN_BOOTSTRAP_ENABLED=true + ADMIN_BOOTSTRAP_SECRET, and only callable from
localhost:
curl -X POST http://localhost:8080/api/users/admin/bootstrap \
-H "Content-Type: application/json" \
-H "X-Admin-Bootstrap-Secret: $ADMIN_BOOTSTRAP_SECRET" \
-d '{"email":"admin@localhost","password":"<your-password>"}'Then log in with that email (not admin) and password. POST /users/admin/reset (same
header) replaces the existing admin if you need to rotate the password.
docker compose up -d postgres # Start vault DB
docker compose down # StopVault DB: jdbc:postgresql://localhost:5432/dba_agent (postgres/postgres)
./scripts/self-host/install.sh # builds + starts everything
docker compose ps # postgres, valkey, backend, deepsql-agent, frontendThe DeepSQL Agent is the fifth container (agent/Dockerfile): Agent tab, AI
dashboards, Slack/CLI agent turns, and per-user profile provisioning on :8787/:8788.
No host-side agent install is required for Compose deployments.
backend/
src/main/java/com/dbaagent/
controller/ # REST endpoints
service/ # Business logic
service/brain/ # ML-based DB intelligence (workload, config, query optimization)
model/ # JPA entities
repository/ # Spring Data repositories
provider/ # Database dialect registry (PostgreSQL, MySQL)
config/ # Spring configuration
security/ # JWT auth, RBAC, admin profile switch (`ImpersonationService`)
llm/ # LLM provider registry, config resolver, OpenAI-compatible provider
util/ # Shared utilities
src/test/ # JUnit 5 tests
src/main/resources/
db/migration/ # Hand-maintained SQL changelog (V5-V109) — NOT executed.
# There is no Flyway: pom.xml has no flyway-core and
# `mvn dependency:list` finds no org.flywaydb artifact.
# Schema is managed by spring.jpa.hibernate.ddl-auto=update.
# The directory even carries duplicate versions (V31, V63,
# V103) that a real Flyway runtime would refuse to start on.
# Apply anything here by hand with psql.
src/ # Frontend (React)
components/ # UI components
tabs/ # 40+ specialized tabs
sections/ # Top-level sidebar destinations (Agent, Dashboards, Brain,
# Performance = Slow Queries + Workload, Editor)
lib/
api/client.js # Centralized API layer (axios, 25+ modules)
stores/ # Zustand stores (dashboard, connection, chat, UI)
hooks/queries/ # TanStack Query v5 hooks
pages/ # Page components
docs/ # Documentation
mcp/ # DeepSQL Phase 1 MCP server (Node stdio wrapper around backend APIs)
agent/ # DeepSQL Agent (persona, skills, skins, Dockerfile for the Compose service)
Cross-platform Electron client for a self-hosted DeepSQL VM. Separate npm
project — cd desktop && npm install, not part of the root package.json.
cd desktop
npm start # run
npm run dev # run with DevTools
npm test # drift guard for the DevTools kill switch
npm run dist:mac # dmg + zip (arm64 + x64), also :win / :linux
npm run smoke -- --url https://deepsql.example.com # headless connection check
npm run selftest:tunnel # end-to-end SSH tunnel test (in-process SSH server)
npm run selftest:settings # proves an edited setting reaches the live connectionA saved profile edit rebuilds the live connection; saving alone was never the
bug. The launcher persists the form before every Connect and Test, so
profiles.json was always correct — but transport.connect() reused any live
connection unconditionally, so changing a tunnel's remote port and pressing
Connect did nothing, and Test reported a confident pass for settings the user had
just replaced. profiles.transportFingerprint() now decides whether a live
connection still is the connection being asked for; transport.reconcile()
rebuilds it on save (ipc.saveAndReconcile), and Workspace.updateProfile()
re-points the window, since a rebuilt tunnel binds a different local port and so
changes the origin. The fingerprint deliberately excludes name (a rename must
not drop a tunnel) and stickyLocalPort (chosen by us and rewritten every
connect — including it would make a connection differ from itself). A failed
rebuild does not restore the old connection: it was built from settings that
no longer exist, so it stays closed and the failure is reported. Entries also
store a profile re-read after the connect path's trust-on-first-use writes, or
the next connect would see a mismatch it caused itself.
DevTools are disabled in packaged builds, and IS_DEV is the wrong switch for
it. Every window passes webPreferences.devTools: DEVTOOLS_ENABLED, defined in
config.js as !app.isPackaged and nothing else. Do not "simplify" it to
IS_DEV: IS_DEV is also true when DEEPSQL_DESKTOP_DEV=1, which any user can
export against the shipped app — that is precisely the hole this closes, and it
used to open DevTools automatically on both windows with no menu item involved.
devTools: false is the load-bearing part (Chromium then refuses to attach at
all, making openDevTools() a no-op); removing the menu item only hides the
door, though it also drops the Alt+Cmd+I/Ctrl+Shift+I binding, since a custom
Menu.setApplicationMenu means Electron contributes no toggleDevTools role.
Separately, index.js exits on --remote-debugging-port and friends: those open
a DevTools protocol endpoint that devTools: false does not cover. Verified
behaviourally on Electron 43 (devTools:false → isDevToolsOpened() stays false
after openDevTools(); a devTools:true control opens, so the check is not
vacuous). desktop/src/main/devtools.test.js fails the build if a new
webPreferences block omits devTools — the regression is otherwise silent,
since Chromium's default is enabled.
It is a thin client and deliberately does not bundle the React frontend. It
navigates a WebContentsView at the real DeepSQL origin, so the UI is always the
version the VM is running — no bundle/backend skew, and no second copy of 40+
tabs to maintain. docker/nginx/default.conf already serves the SPA, /api and
/agent-api from one origin, so cookies and SSE behave exactly as in a browser.
Do not "improve" this by bundling dist/ — that reintroduces SameSite and
version-skew problems the current design does not have.
It needs exactly one piece of backend configuration, and CORS is it. The
"zero backend changes" claim that used to sit here was wrong, and cost a long
debugging session. Over a tunnel the origin is http://127.0.0.1:<sticky port>,
not the VM's hostname, so a deployment whose CORS_ALLOWED_ORIGINS names only
its public hostname rejects the desktop client. The failure is maximally
misleading: Chromium omits Origin on same-origin GETs, so the health probe,
the SPA and every read succeed, and the first POST — the login — comes back
403 with the plain-text body Invalid CORS request. That body has no
message field, so client.js's axios interceptor falls through to axios's own
wording and the user sees "Request failed with status code 403", which names
neither CORS nor the origin. Fix: keep loopback patterns in the allowlist —
CORS_ALLOWED_ORIGINS=https://your-host,http://127.0.0.1:*,http://localhost:*.
Port wildcards work only because SecurityConfig uses
setAllowedOriginPatterns; setAllowedOrigins would reject * alongside
allowCredentials(true). probe.js now sends an Origin header for exactly
this reason, so the rejection is caught at connect time and named.
Two transports, one abstraction. Both resolve to an origin, so nothing
downstream of desktop/src/main/transport.js knows which is in use:
- Direct TLS — the VM's HTTPS origin. Four certificate modes (
system,pinned,custom-ca,insecure/TOFU), applied to both the Node health probe and the Chromium session (tls.applyToSession). Applying it to only one gives a connection that tests green but renders a certificate error. - SSH tunnel —
ssh2local forward, loopback-bound, nosshbinary needed. The local port is sticky across launches on purpose: the origin includes the port, and a fresh random port would silently reset the web app'slocalStorage.http://127.0.0.1:*is a Chromium secure context, so the backend'sSecurecookies still work over the tunnel. Forward to the frontend container (3000), not a host reverse proxy on :80 — that proxy matches onserver_name, a tunnel arrives withHost: 127.0.0.1:<port>, and the request lands on the default vhost as a 404 that reads like a broken backend. The container's nginx usesserver_name _and answers any Host.
Three non-obvious things, all found the hard way:
Client.connect({ privateKey })must get the raw key material, not the objectsshUtils.parseKeyreturns. Handed a parsed key, ssh2 silently never offers the publickey method and the server replies with a bare authentication failure — a symptom that points at the VM'sauthorized_keysrather than at a type mismatch on our side.loadPrivateKeyparses only to produce good error messages and returns the buffer.- Authentication succeeding says nothing about forwarding being allowed.
A hardened sshd (
AllowTcpForwarding no) accepts the login and refuses everydirect-tcpipchannel; the failure otherwise surfaces as "socket hang up" on the first browser request, pointing nowhere near sshd.verifyForwarding()opens and closes one channel right after auth and classifies the refusal by SSH reason code — 1 (ADMINISTRATIVELY_PROHIBITED, verified against real OpenSSH) namesAllowTcpForwarding, 2 (CONNECT_FAILED) means nothing is listening on the remote port. - Only a session that once reached
readymay be reconnected. Gating reconnects oneverReadyis what stops a connect that fails on authentication from retrying forever behind a caller that already surfaced the error.
Secrets (key passphrases, SSH passwords) are stored as safeStorage ciphertext;
where no OS keychain exists nothing is written to disk and the launcher says so.
Each profile gets its own session partition, so two DeepSQL servers never share
cookies. .github/workflows/desktop-release.yml builds all three platforms on
their native runners. See desktop/README.md for the full picture.
mcp/deepsql-phase1-server.jsimplements a Phase 1 stdio MCP server for internal rollout.- Schema/retrieval tools stay read-only.
execute_sqlis role-gated: developers stay read-only; admins can run DML and non-destructive DDL (CREATE,ALTER) with the same two-step confirmation as the SQL Editor.DROPandTRUNCATEstay blocked on MCP even when confirmed. - It wraps existing backend APIs, so it reuses DeepSQL chat orchestration, RAG, connection management, and
QueryExecutionPolicyServiceinstead of exposing raw DB credentials. - Client config examples live in
.cursor/mcp.jsonandmcp/claude_desktop_config.example.json. - Usage and env vars are documented in
docs/root/MCP_PHASE1.md.
Dashboards are generated by the embedded DeepSQL Agent acting as a coding agent
(customized Hermes runtime — see agent/README.md) — it writes the whole dashboard as a single self-contained HTML document, not a JSON spec. The earlier spec+renderer model (metrics/charts/tables + a {{placeholder}} substitution engine + DashboardBuilder.js) was thrown away: the rigid col BETWEEN {{name}} convention couldn't express real SQL (e.g. a Unix-epoch date filter → near '{range.start}' syntax errors) and boxed the agent in.
DashboardAgentServiceis a thin broker:ensureProfileForUser→ensureSession(fresh session) →sendAndAwaitwith an artifact contract. The agent grounds on the brain/schema, verifies every query withexecute_sql, then emits ONE HTML doc (in a```htmlblock). The broker extracts the HTML and returns{version:3, renderMode:"artifact", title, html, trace}, stored verbatim insaved_dashboards.dashboardConfig.- The agent loads the
dashboard-designskill (agent/skills/dashboard-design/SKILL.md, v2 — artifact contract, thedeepsql.queryruntime, composition/UX rules, an intent checklist, and Unix-epoch date handling). - Rendering + data access:
DashboardArtifact.jsxrenders the HTML in a sandboxed iframe (sandbox="allow-scripts", opaque origin + a strict CSP — no external network). The artifact fetches data only through an injecteddeepsql.query(sql)bridge thatpostMessages to the parent; the parent callsPOST /api/dashboards/query(DashboardQueryController), which is read-only twice over (McpSqlGuardService.validateReadOnlySql+QueryExecutionContext.api=READ_ONLY_ONLY) and access-scoped viaassertCanReadConnectionContent. So the agent's code has full creative freedom while every query stays guarded and sandboxed. The bridge also auto-sizes the iframe and forwards runtime errors. - Generation endpoints unchanged (
POST /api/dashboards/generate+/generate/stream).DashboardBuilder.js/DashboardInputs.jsremain only becausetabs/Core/PreviewTab.jsstill uses them — the dashboard creation path no longer touches them. - Sharing: both share types render a standalone read-only
DashboardViewer(title +DashboardArtifactwith an injectedqueryFn). Internal link/dashboard-view/:id(auth) uses the authed broker; public link/share/dashboard/:token(permitAll) usesPublicDashboardController(GET /api/public/dashboards/{token}+/query), which resolves only whilesaved_dashboards.is_publicis true (revoke = flip it) and runs read-only + connection-scoped.share_token/is_publicare set only viaPOST|DELETE /api/saved-dashboards/{id}/share(access-checked), never a general update.ShareMenu.jsxdrives the UI. The public query path has its own nginxdashqlimiter. - Organization (search/folders/favorites):
SavedDashboardController's search/folder/favorite endpoints existed for a while with no UI consumer.DashboardsHome.jsxnow wires all of it — a search box (client-side filter over name/description), folder chips derived fromGET /connection/{id}/folderswith a per-card "move to folder" popover (PUT /saved-dashboards/{id}withfolder: ""to clear —updateDashboardtreatsnullas "field omitted" so blank is the explicit clear signal, same convention assetSharePassword), and a favorite star toggle (POST /{id}/favorite) with optimistic UI update. - Clone:
POST /saved-dashboards/{id}/clone(SavedDashboardService.cloneDashboard) duplicates a dashboard's config/chat/tags/folder into a fresh row — not shared, not favorited. Exposed as a copy icon on eachDashboardsHome.jsxcard. - Version history: every real overwrite of
dashboardConfig(agent build viacompleteBuildTurn, manual Source-tab edit viaupdateDashboard, or a restore) snapshots the previous config intodashboard_versions(V113__create_dashboard_versions.sql) before overwriting, tagged with a trigger (AGENT_BUILD/MANUAL_EDIT/RESTORE) — capped at 50 snapshots per dashboard, oldest pruned first.GET /{id}/versionslists them newest-first;POST /{id}/versions/{versionId}/restoreswaps a snapshot back in as current (itself snapshotting whatever was live, so a restore is undoable too) and dedupes: after a restore, the restored row plus any other row with byte-identicaldashboard_configare deleted, since that content is now "Current," not history — otherwise a restore-edit-restore cycle piles up an alternating chain of duplicate snapshots.DashboardWorkspace.jsx's History panel shows a lightweight diff summary per entry (title/widget-count/size delta computed client-side, not a real line diff — the agent rewrites large chunks even for small logical changes) plus a Preview modal that renders that version's HTML live viaDashboardArtifact. - Refresh:
DashboardArtifact'suseImperativeHandleexposesreload(), which bumps an internalreloadEpochstate used as the<iframe>'skey— forcing a genuine remount (and re-running every widget'sdeepsql.query()call) even whenhtmlis referentially unchanged, which changinghtml/srcDocalone can't guarantee.DashboardWorkspace.jsx's canvas toolbar has a manual Refresh button plus an auto-refresh interval dropdown (Off/30s/5m/1h) that calls it on a timer, paused while a build is in flight (a completing build already replaces the iframe).DashboardViewer.jsx(both share surfaces) takes the sameautoRefreshMsoptionally, plushideChromefor kiosk mode. - TV/kiosk mode:
PublicDashboardPage.jsxreads?kiosk=1&refresh=<seconds>(chrome-less + auto-refresh, floor 10s) and?tokens=tokA,tokB&advance=<seconds>(cycles through multiple public share tokens, dwellingadvanceseconds each — the route's own:tokenis always the first slide). A password-protected dashboard mid-cycle is skipped (there's no one there to type a password) rather than parking the whole kiosk on a gate.ShareMenu.jsxsurfaces a ready-made kiosk link (?kiosk=1&refresh=60) once a dashboard is public and unprotected. - Alerts:
dashboard_alerts(V114__create_dashboard_alerts.sql) holds a natural-language condition per dashboard (e.g. "alert if the error rate exceeds 5% in the last hour"), evaluated on a schedule byDashboardAlertService.evaluate()— a bounded agent session (freshensureSession, no tools beyondexecute_sql/schema lookups, a short task prompt asking for exactlyYES/NO+ a one-sentence reason grounded in a real query result) reusing the same agent plumbing as dashboard generation, just for a one-line answer instead of a whole HTML document.DashboardAlertTaskConfigregisters one db-scheduler recurring task (dashboard-alert-tick, every minute) that evaluates whichever alerts are actually due perDashboardAlertRepository.findDue(each alert has its owncheckIntervalMinutes) rather than one scheduled task per alert. A fired alert dispatches throughEmailService.sendDashboardAlert/WebhookService.sendDashboardAlert(new methods, same pattern as the existing growth/slow-query alert methods) gated by a per-alertcooldownMinutesso a condition that stays true doesn't re-fire every tick. The alert runs as whoever created it (createdByUsername, captured at creation time) — there's no ambient "system" identity for a background job, and running every alert as an arbitrary admin would let one user's alert read data through someone else's access grant.DashboardAlertControlleris the CRUD surface (/saved-dashboards/{id}/alerts);DashboardWorkspace.jsx's toolbar has an Alerts panel (composer + per-alert enable/disable/delete, last-check verdict shown inline).
com.dbaagent.llm holds the provider abstraction. LlmProviderRegistry auto-discovers
LlmChatProvider / LlmEmbeddingProvider beans, exactly like DatabaseProviderRegistry,
indexing chat and embedding in separate maps (Anthropic publishes no embeddings API;
one shared index would force the if/else-on-provider-type this file forbids). Duplicate
ids or aliases fail fast at startup. Startup logs
Registered 1 LLM chat providers [openai] and 1 embedding providers [openai].
OpenAiCompatibleChatProvider / OpenAiCompatibleEmbeddingProvider are the only shipped
implementations, both under the id openai. One provider covers OpenAI, Azure OpenAI, and
self-hosted vLLM/Ollama/LM Studio/TGI — it dispatches on the endpoint shape, not on a
provider id: an .azure.com / .azure-api.net base URL switches to Azure's api-key
header, everything else uses Authorization: Bearer.
Configuration resolution — LlmConfigResolver, two tiers, no property-default tier
(a credential default in a properties file is how the production Azure key reached git
history):
- Database —
llm.<role>.provider, thenllm.<role>.<providerId>.<field>insystem_config. - Environment —
DEEPSQL_{CHAT,EMBEDDING}_{PROVIDER,API_KEY,ENDPOINT,MODEL,…}.PROVIDERgates the whole bundle: unset, nothing else is read.
SetupController's /setup/llm-config writes the namespaced keys above for both
roles from the single credential the wizard collects, and /setup/status derives
hasLlmConfig from resolveChat() != null rather than from a config key. It previously
wrote a flat pre-BYO namespace (llm.provider, llm.openai.api-key, llm.chat-model,
llm.embedding-model) that intersected nothing the resolver reads, so the wizard stored
keys that did nothing and every env-configured install reported itself unconfigured.
V109__drop_legacy_llm_config.sql removes those orphaned rows (apply by hand — this repo
has no Flyway runtime).
Embedding hazard: embeddings must resolve through a single provider. Spring AI will
auto-configure its own EmbeddingModel from spring.ai.openai.* if one is not supplied,
giving VectorStore and QuestionAnswerAdvisor a second, independent embedding source.
LlmConfig.embeddingModel registers ProviderBackedEmbeddingModel as @Primary so it
wins by type — but @Primary alone is not enough, because OpenAiEmbeddingAutoConfiguration
builds eagerly and asserts spring.ai.openai.api-key is non-empty, so it is also excluded
outright in DbaAgentApplication. Do not reintroduce either half. A store written by one
embedding model and read through another raises no error — retrieval just degrades silently
(pgvector's text-column fallback has no dimension constraint and cosine similarity still
returns a number).
- Database Provider Registry: Use
DatabaseProviderRegistryfor all DB-specific operations. Do NOT add if/else or switch for database types. - LLM Provider Registry: Use
LlmProviderRegistryfor all provider-specific LLM behavior. Do NOT add if/else or switch on provider type. Chat and embedding providers are registered and resolved independently — some providers offer only one. Providers are factories over credentials, notChatModels, so credentials stay resolvable per call and key rotation needs no restart. - SSH-Aware Access: Always use
ConnectionService.getJdbcTemplate(connectionId, request)— handles SSH tunneling transparently. - SQL Rule: All generated SQL MUST use table-qualified column names (
table.column_name). - Chat access policy: Fail closed. Walk the whole SQL tree (CTEs, set ops, subqueries). Deny unparseable or unhandled statements. Require an actor except
INTERNAL/SCHEDULED. MCP/Editor identity comes fromSecurityContext, notQueryActorContextHolder. Persistallowed_schemas. Do not let "how many" override a protected-column mention. Public share is refused when the connection has an active policy. - RAG Caching: Three-tier cache (memory → Redis → Azure Search). Redis failure is graceful (app continues without caching).
- Virtual Threads: Enabled for concurrency (JDK 25).
- API Centralization: ALL API calls through
src/lib/api/client.js. Never create direct axios instances. - Server State: Use TanStack Query hooks from
src/lib/hooks/queries/(not useState/useEffect for data fetching). - UI State: Use Zustand stores from
src/lib/stores/. Prefer selector hooks for optimized re-renders. - Tooltips: Always use
HelpTooltipcomponent, never plaintitleattributes. - Design: Minimal black/white/grey palette, Inter font, subtle transitions. See UX guidelines in full CLAUDE.md.
Roles are not a hierarchy. The old model ranked DEVELOPER < ADMIN and compared
ordinal(); the shipped roles deliberately overlap without nesting, so an ordering
comparison has no meaning and Role.isAtLeast is gone.
| Role | Sections | Notes |
|---|---|---|
ADMIN |
everything | Fixed point: holds every permission; overrides against it are refused, so the last admin cannot be locked out of user management. |
DBA |
all menus + connection settings | No user creation / invite codes / role management. |
DATA_ENGINEER |
Agent, Dashboards, Editor | No Digest, no Performance. |
DEVELOPER |
Agent, Digest, Dashboards, Performance, Editor | No connection settings. |
| custom | whatever an admin ticks | custom_roles rows; the code is written to users.role. |
- Permissions are the unit of authorization.
Permissioncarries the built-in roles that hold it by default (defaultRoles); oneVIEW_*permission per sidebar section (VIEW_AGENT,VIEW_DASHBOARDS,VIEW_DIGEST,VIEW_BRAIN,VIEW_PERFORMANCE,VIEW_EDITOR). The frontend gates nav on those codes (SECTION_PERMISSIONinsrc/lib/features.js), not on a minimum role. - A "role code" is either a built-in
Rolename or aCustomRole.code— they share theusers.rolenamespace, soCustomRoleServicerefuses a code colliding with a built-in one.Role.fromStringreturns null for anything unrecognised instead of collapsing to DEVELOPER: mapping a custom role onto a built-in one would hand its holders the wrong permissions. UsePermissionService.getEffectivePermissions(roleCode)—User.getRoleEnum()is null for a custom role andRole.getPermissions()skips overrides. - Every token-minting path must resolve by role code.
AuthSessionService,PasswordlessAuthService,AuthInternalController,CustomUserDetailsServiceand the/auth/mepayload all useuser.getRoleCode()+PermissionService;JwtUtilgained aString roleCodeoverload for exactly this. ARole-typed path cannot represent a custom role, so a custom-role user would silently get the wrong claim. - An unknown role code grants nothing rather than falling back — a deleted custom role must not become silent Developer access. Deleting a custom role is refused while any user still holds it.
RolePermissionOverride.roleis now a role-code string (same column), so overrides work for custom roles too. Built-in role permission sets are code, not data: the API refuses to edit them directly and points at overrides instead, so an admin's change survives an upgrade.
- There is one access level.
ConnectionAccessLevel.CHAT_EDITORis@Deprecatedand retained only so pre-existing rows parse;fromStringfolds it (and a blank value) intoFULL_CONTENT, andConnectionAccessService.resolveAccessreturnsFULL_CONTENTfor every grant. Assigning a connection therefore implies content access — no migration was needed, legacy rows upgrade themselves on read. The "Full Access" / "Chat + Editor" badges are gone; only Owner/Admin are surfaced. AccessControlServiceTestcannot prove anything about this. It stubsresolveAccessto return a fixedEffectiveConnectionAccess, so its CHAT_EDITOR case passes vacuously no matter what the resolver does.ConnectionAccessLevelCollapseTestexercises the real path — add coverage there, not to the stubbed test.POST /connectionshad no authorization at all. It went straight to test-and-save, so any authenticated user could create — then edit and delete — their own connection (verified live: the row persisted withowner_username = analystfor a DATA_ENGINEER). Hiding the sidebar button is not a control. It now callsaccessControlService.assertCanManageConnections(), which is permission-based, not admin-only, so DBA and any custom role holdingMANAGE_CONNECTIONSstill work. Creation is not scoped to a connection id, so none of theassertCanManage*Connection*helpers apply — a new unscoped endpoint needs this guard explicitly.- Settings and Connections are admin surfaces in the UI.
SettingsModalandManageConnectionsModaleach refuse to render without the relevant permission, enforced inside the component rather than only at the call site: both are opened from several places, and gating each entry point separately means the next one silently reopens the hole. Hiding Settings also removes MCP tokens from those roles — that is intended.
DashboardWorkspace groups dashboards within one connection and carries its own member
list (DashboardWorkspaceMember, keyed by username to match connection_access_grant
so "View as" resolves membership as the target user).
- The rule is an AND, and it only ever narrows. Connection access is checked first and
unchanged (
assertCanReadConnectionContent); workspace membership is an additional gate. Adding someone to a workspace can never grant them a connection they were not already given.saved_dashboards.workspace_idis nullable — NULL means "not grouped", governed purely by the connection ACL exactly as before. - Admins bypass the membership half, matching how they already bypass connection grants.
- Non-membership reports 404, not 403 — a user outside the workspace must not learn the dashboard exists.
- Deleting a workspace detaches its dashboards, never deletes them (the FK is deliberately non-cascading). Removing the last MANAGER is refused, otherwise the workspace could never be changed again by anyone but an admin.
DashboardWorkspaceService.filterReadableresolves a whole list in one membership query; use it for any new dashboard-list endpoint rather than checking per row./saved-dashboardshad no connection authorization at all before this change — create, list, get, update and delete took a caller-suppliedconnectionId/id and checked nothing, so any authenticated user could read every dashboard on every connection (verified live against a running install, not inferred). All of them now assert connection access and the workspace gate;DashboardAlertControllerdoes the same through its singlerequireDashboardchoke point. This is the same "authentication is not authorization" trapBrainControllerdocuments — there is still no filter doing it for you.
Admins can View as a sub-user from the top-right of the home layout (ProfileSwitch) to verify connection ACLs, chat/editor policies, and role-gated nav.
The admin JWT subject stays the administrator so logout, refresh, and /admin/impersonate still own the real session. Policy identity is the target: an httpOnly impersonate_user cookie plus an impUid claim on the access token. JwtAuthenticationFilter overlays that principal onto the SecurityContext for every request except the impersonation control plane, logout, and session refresh. Chat, Editor, schema listing, and Agent MCP calls then run AccessControlService / ConnectionChatAccessPolicyService as the target (actorIsAdmin is false, so policies apply).
The Agent tab must not inherit the admin MCP token. /api/agent/session mints an MCP token for the effective user and never falls back to the admin session JWT while View as is active. nginx auth_request on /agent-api forwards /api/auth/me's X-Remote-User (the overlaid username) instead of hardcoding admin.
POST|DELETE|GET /api/admin/impersonate are excluded from the overlay so stop/list still run as the real admin. Cannot target another ADMIN, self, or a non-ACTIVE account. /auth/me returns the effective user plus impersonating / impersonatorUsername.
- Do NOT commit automatically — wait for explicit user instruction.
- Conventional commits:
feat:,fix:,refactor:,docs:,test:,chore:,perf:,ci:
-
Pin the Python MCP SDK below 2.0.
scripts/self-host/setup-agent.shinstalls it asmcp>=1.0,<2. SDK 2.0.0 renamedCallToolResult.isErrortois_errorand split the models into a separatemcp-typespackage, whilehermes-agent0.20.0 still readsresult.isError(tools/mcp_tool.py:5222). With the old unboundedmcp>=1.0, every DeepSQL tool call raisedAttributeError: 'CallToolResult' object has no attribute 'isError'— a time bomb that detonated the day 2.0.0 shipped, with no code change on our side. Raise the ceiling only once hermes readsis_error. -
Three tool failures trip hermes's circuit breaker, after which the rest are refused as
MCP server 'deepsql' is unreachable— blaming a healthy server for a client-side parse error. Do not trust that message; find the first failure in~/.hermes/logs/errors.log. -
A running webui holds its SDK in memory. After changing the SDK it must be restarted;
setup-agent.shnow does that itself. It previously printed✓ Hermes webui already runningand left the broken SDK loaded, so re-running the repair script gave a full column of ticks and no change. -
The agent version was never the problem.
agent/distribution.yamlonce pinnedhermes_requires <0.20.0on a "verified" 401 that came from a hand-rolledhermes serverun rather thanhermes webui. 0.20.0 works. Verify against the real start path before writing a version constraint. -
Hermes MCP is process-global. One
deepsql-phase1-server.jsstdio server is started from the first loaded profile (usuallyu-admin) and lives until the Agent API process dies.POST /api/profile/switchisprocess_wide=Falseon purpose (per-browser cookie), so a View as / new-thread Agent chat taggedprofile: u-marts-editorstill sends the admin MCP bearer. Chat-access policy thenresolveEffectivePolicy(..., actorIsAdmin=true)→none(). The provisioner writes the target user's token to$HERMES_HOME/deepsql.token, the one shared path the live process may have started from (DEEPSQL_TOKEN_FILEmtime cache inmcp/deepsql-phase1-lib.js).Never fan that write out across
profiles/*/deepsql.token. It did once, and made the agent credential globally last-writer-wins: any user opening the Agent tab overwrote every other user's token, so their agent authenticated as the newcomer. Verified end to end —analyst's agent read an admin-only connection (403 on their own session, 200 with the agent token, 133 vault tables) and theEDITOR_QUERY_EXECUTEDrow named admin, not analyst. Two concurrent users was the whole trigger; no impersonation needed. The provisioner self-test asserted the fan-out as correct (it modelled only the View-as case, where overwriting is desired), so a green suite guarded the bug — it now asserts the opposite, that provisioning B leaves A's token intact.Because that root file is still shared, the real guard is server-side:
McpTokenAuthenticationFilterrefuses an MCP token whose owner differs from the request'sDEEPSQL_MCP_USER_IDclaim (sent asX-DeepSQL-Client-Agent), answering401 mcp_identity_mismatch. The claim is only ever used to refuse, never to grant, so forging it cannot widen access. A claim that isn't a real DeepSQL username is ignored, which is what keeps editor/CLI MCP installs (cursor,claude-desktop, any--caller-agent) working.probeMcpAuthsends the same header so the boot health check exercises the binding instead of bypassing it. -
The agent image build clones two third-party repos over the public internet, unauthenticated.
agent/DockerfilefetchesNousResearch/hermes-agentandnesquena/hermes-webuiat build time. GitHub rate-limits unauthenticated requests per source IP, and Actions runners share pooled egress addresses, sodocker compose buildintermittently died onfatal: unable to access ...: The requested URL returned error: 429(exit 128) — 2 of 15 runs, always on branches whose diff had nothing to do with the agent. Both clones now retry 5x with backoff, and still print FATAL and exit 1 on exhaustion so a genuinely dead upstream cannot yield an image with no runtime in it. Two lessons worth keeping: a CI failure that is intermittent and unrelated to the diff is a network or rate-limit signature, not a code defect — read the log before bisecting the branch; and the webui clone's pre-existing|| git clonefallback looked like resilience but only ever handled a moved ref, re-issuing the identical refused request against a 429. A fallback that fails the same way as the thing it backs up is not a fallback. -
Never offer a write the caller cannot enforce.
SOUL.mdonce asked "Should everyone on this database see this?" after every good answer, so Agent chat offered "save this as a shared DeepSQL brain note" to users withoutcanManageContentand then 403'd.get_brain_contextnow stampscallerCapabilities; ifdoNotOfferincludessave_brain_note, the agent must not mention it. MCPsave_brain_notealso fail-closes before the POST. Admins get a non-blocking suggestion bubble only after they correct or teach the Agent (POST /brain/notes/propose+ accept) — a clean first answer stays quiet. Overlaps merge into one intent.
These all reported success over broken systems — which is how the agent shipped broken. Assert the outcome, never the attempt:
e2e-agent-check.pypassed onany("execute_sql" in t for t in tools)— a tool being attempted. It printed✓ All agent UI paths OKand exited 0 while the agent's own reply said "I'm blocked". It now requires the answer itself.- A dashboard that is "HTML and long" proves nothing: with every tool failing, the
agent emitted a plausible artifact full of invented numbers. A real one calls
deepsql.query(); absence of that call means the data never came from the database. - Presence ≠ compatibility. The SDK check tested only that
mcpimports, so it printed✓ Python MCP SDK availableon an SDK whose every call failed. It now assertsCallToolResultstill carriesisError. - Mocks hide SDK breaks.
tests/tools/test_mcp_structured_content.pyuses a_FakeCallToolResultwith a hardcoded.isError, so it kept passing precisely when the real SDK stopped matching. Pin the dependency; a fake cannot catch this. - A self-host verification script must reach the DB the way the install does.
seed-review-suggestions.py/e2e-review-approvals.pyhardcodedsudo -u postgres psql, which only exists on a bare-metal install — on the Compose deploymentinstall.shactually produces, the documented verify command died before testing anything. Both now resolve the path throughscripts/self-host/vaultdb.py. - A test that mutates shared state must restore it, and only what it created. The
same e2e suite parked every real
CODE_DERIVEDrow by rewritingsourcetoUSERand never restored it, so a run against a live install silently relabelled the user's approved docs. It now copies rows to a scratch table and restores them, and its cleanup deletes the planted row only while nothing references it. - Never claim a check you did not run.
install.shreported "up to date" when it could not reach npm; it now says it could not check. set -e+readat EOF aborts silently. Prompts ininstall.shuseread … || trueso the explicit emptiness checks report the problem. Without it the installer exited 1 with no message, after writing generated secrets to.env.- Minting an Agent MCP token ≠ Hermes using it.
/api/agent/sessioncan mintu-marts-editor's token andPOST /api/profile/switchcan 200 whileexecute_sqlstill authenticates as admin. Hermes keeps one DeepSQL MCP stdio process (started from the first profile that loadedmcp_servers) andprofile/switchisprocess_wide=False. A new chat thread does not respawn MCP.probeMcpAuthonly proves the minted token works against Spring, not that the live MCP process will send it. The provisioner must mirror the token onto everydeepsql.tokenthe live process might be watching (scripts/local-agent-provisioner.py). Audit:security_event.user_idonEDITOR_QUERY_EXECUTEDwithclientType=mcp. - Silent-failure rule, concretely: the CLI rendered an unreachable server as
No databases connected yetbecause onecatchcovered both the connection fetch and decorative extras. An unreachable host must never look like an empty account. - SQL mutation guards must match statement verbs, not identifiers.
McpSqlGuardService/mcp/deepsql-phase1-lib.jsused\bCOMMENT\b/\bCALL\b, soSELECT * FROM commentwas rejected as "potentially mutating." Plenty of schemas have acommenttable. AssertSELECT * FROM commentis allowed and thatWITH x AS (DELETE …) SELECT …/WITH x AS (…) DELETE …still are not.
The Editor (EditorSection → SqlRunnerTab → POST /connections/{id}/query →
QueryExecutionPolicyService → QueryExecutorService) is the only surface where a
user submits arbitrary SQL. Everything below was a live bug, verified by executing
it against a real database — not a theoretical hardening pass.
- Never classify SQL by its leading keyword alone.
WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM xparses as aSelectand every leading-keyword check calls it read-only — includingisReadOnlyQuery, which reports anything starting withWITHas safe. PostgreSQL executes data-modifying CTEs for real, so a non-admin wiped whole tables through the Editor with no confirmation prompt, logged as an ordinaryEDITOR_QUERY_EXECUTED / SUCCESS.SELECT … INTO newtabis the same class of bug (it is DDL).classifyStatementnow inspects the parse tree (detectSelectWrite) and runs a text backstop (detectHiddenWrite) so an unparseable variant fails closed instead of falling through to the keyword path. - Read-only contexts open read-only JDBC sessions.
QueryExecutorServicecallsconnection.setReadOnly(true)whenevermutationMode() == READ_ONLY_ONLY, so PostgreSQL refuses the write itself even if classification is wrong. Classification is a parser heuristic; this is what keeps the next parser gap from being data loss. A driver that rejects the hint raises rather than silently continuing writable. HikariCP resets the flag on return to the pool (verified), so it cannot leak into an admin's later write. - Row caps are enforced with
setMaxRows, not by appendingLIMIT n. The old check skipped its own LIMIT whenever the regex\blimit\s+\d+matched anywhere — including inside a comment, a string literal, or a subquery.WITH a AS (SELECT … LIMIT 100) SELECT * FROM ais ordinary analyst SQL and returned 200k rows against a 1,000 cap, straight into an unboundedArrayListand then an unvirtualized table. The SQLLIMITis still appended for simple SELECTs, but only as an optimization — correctness no longer depends on that text match. - Cancel must terminate the query, not just the HTTP request.
abortController .abort()only closes the socket; the statement runs on holding one of the pool's 10 connections for up to its timeout. The client now sends anexecutionId,RunningQueryRegistrymaps it to the backend session pid (via the dialect'sgetSessionPidQuery()), andPOST /connections/{id}/query/{executionId}/cancelterminates exactly that session. The previous UI behavior was worse than nothing: it killed every active query on the connection, including other users' work. The cancel endpoint is scoped to the connection and the user who started the run, so an execution id is not a kill primitive for someone else's query. - Keep the client timeout under the proxy's.
docker/nginx/default.confgives up atproxy_read_timeout 300s; the Editor used to ask for 600s, so a 6-minute query returned an opaque 504 while still running.QUERY_TIMEOUT_SECONDS = 240inSqlRunnerTab.js— change both together or not at all. /api/connections/*/queryis rate-limited in nginx (limit_req zone=sqlexec, 30r/m + burst 20,429on reject). It is the most expensive authenticated call in the product.- Test the policy against the real providers.
QueryExecutionPolicyServiceTestused to stubisReadOnlyQueryto always returnfalse— the exact opposite of what the shipped providers do forWITH. It asserted behavior no deployment had, andwithInsert_isTreatedAsMutationpassed because of the stub. It now constructs a realMySQLQueryExecutionProvider. Do not reintroduce a stubbed dialect here; the mock is what let the blocker ship.
-
mcp_tokens.user_idis a non-null FK with no cascade. Deleting a user who holds a token throwsConstraintViolationException.UserControllerclears the user's tokens first viaMcpTokenRepository.deleteByUserId, which carries its own@Transactional— a derived delete needs one, and annotating a self-invoked caller does nothing (Spring proxies are bypassed bythis::). This brokePOST /users/admin/reseton every install that had runsetup-agent.sh, since that mints an admin MCP token on each run. -
An
Optional-returning derived finder is an assertion that the key is unique. Spring Data throwsIncorrectResultSizeDataAccessException("Query did not return a unique result: N results were returned") the moment it is not, and the row that broke it never repairs itself, so the failure is permanent rather than transient.schema_documentationhad no unique constraint on(connection_id, object_type, object_name, parent_object, source)andCodeSuggestionApplier.approvetook no row lock, so one bulk approve submitted twice concurrently wrote 219 duplicate pairs. Every later approve touching one of those keys threw,CodeScanService.bulkDecideswallowed it per item, and the Review queue reported "Approved 0 of 2" — with all 198 pending SCHEMA_DOC suggestions wedged. Three-part fix, and all three are load-bearing:V116__dedupe_schema_documentation.sql+SchemaDocumentationDedupeInitializer(no Flyway here, so the initializer is what actually applies it) collapse the duplicates and addux_schema_doc_target, keyed oncoalesce(parent_object,'')because Postgres treats NULLs as distinct.- Those finders now return
List, andSchemaDocumentationDeduplicator.collapsekeeps the newest row, repoints anyapplied_doc_idoff the rows it deletes, and drops their RAG embeddings. Do not restore anOptionalvariant — legacy installs still carry duplicates until the initializer runs. approve/rejectload the suggestion viafindByIdForUpdate(PESSIMISTIC_WRITE) so the concurrent double-submit that created the duplicates blocks instead of racing.
-
applied_doc_idis a loose reference, not an FK. Deleting aschema_documentationrow it points at raises nothing and dangles silently — repoint before deleting. -
Approve updates the row an earlier scan wrote, so a "freshly approved" doc row carries a historical
created_at. A test that plants an "old" duplicate with a hardcoded past date can easily plant the newer of the two and assert nothing; anchor fixture timestamps to the real row'screated_at. -
A write's blast radius decides what to invalidate, not the endpoint you called. Approving a code-scan suggestion writes
code_knowledge_suggestionandschema_documentation(served bybrain/notes, which backs the Write-notes tab and its coverage counts) andrag_documentsand, for KNOWLEDGE_ENTRY, a company knowledge entry. The decide hooks invalidated onlycodeScan+companyKnowledge, so every schema-doc-derived count stayed stale until the user reloaded the page.invalidateAfterDecisioninuseCodeScan.jsis the single place that lists them; add to it when an approval starts writing something new. -
@PreUpdatedoes not fire on insert, soupdatedAtis null on a brand-new row. Sorting "newest first" onupdatedAtalone with nulls last therefore sends every freshly created row to the bottom — which is why a just-approved note did not appear at the top of the Write-notes list. Sort onCOALESCE(updatedAt, createdAt)(BrainNoteService.touchedAt,CompanyKnowledgeEntryRepository.findByConnectionIdOrderByRecency). -
Suggestion list order depends on the status being viewed. PENDING is a work queue →
confidence DESC. APPROVED/REJECTED are history →decidedAt DESC NULLS LASTso the decision you just made is at the top; confidence-sorting a decided list scattered fresh approvals among hundreds of older ones (CodeScanService.sortFor).
- Authentication is not authorization.
SecurityConfigonly asserts.anyRequest().authenticated()andJwtAuthenticationFilteronly resolves a principal — neither looks at aconnectionId. Connections are private per user (ConnectionAccessService.resolveAccesskeys onownerUsernameplus an explicit grant table), so any endpoint taking a caller-suppliedconnectionIdmust callaccessControlService.assertCanReadConnectionContent(reads) orassertCanManageConnectionContent(writes) itself. There is no filter, interceptor or aspect that does this for you. BrainControllershipped with 93 of its 116 endpoints unguarded. Only the first ~15 (/understanding,/notes/*,/tasks/*,/key-columns/*,/inferred-relationships/*) had the check; every later "Phase" block did not — so an authenticated user could pass someone else's connection id to/brain/health-scores/{id},/brain/data-sensitivity/{id}(which names the PII columns),/brain/cost-attribution/{id},/brain/ml-overview/{id}and ~90 more and read that user's database intelligence. All 116 are now guarded, andBrainControllerAuthorizationSafetyTestfails the build if a new one is not. The misses clustered by when a section was written, not by read/write semantics — when adding a controller section, guard it as you write it.- When the path carries some other id (
simulationId,experimentId,patternId,noteId,taskId), resolve the owning connection first via that service'sgetConnectionId(id)and assert on the result. Do not skip the check because the path has noconnectionIdin it. - An endpoint with no connection scope at all is admin-only.
POST /brain/column-values/embed-allspans every connection, so it carries@PreAuthorize("hasRole('ADMIN')")— it cannot be authorized against one connection's grants.@EnableMethodSecurity(prePostEnabled = true)is on inSecurityConfig, so@PreAuthorizeis live. - Assert inside the
try, and rethrowResponseStatusExceptionbefore the catch-all. Every handler inBrainControllerends with acatch (Exception) -> 500; without the earliercatch (ResponseStatusException e) { throw e; }a 403 is swallowed and reported as a server error, so a client cannot tell "not yours" from "broken". The safety test asserts this too. - Then it happened again, on 12 more controllers — 116 endpoints, zero checks.
BrainControllerAuthorizationSafetyTesthardcodes onePath.of(...), so it could not seeSlowQueryController(43),SlowQueryAnalyticsController(13),SchemaChangeController(13),SentinelAnalyticsController(10),PerformanceActionController(9),QueryPerformanceController(8),QueryPlanController(8),IndexAdvisorController(7),PerformanceInsightsController(5),AdvisorController(3),ResourceLimitsController(3) orBusinessRuleController(3). Verified live, not inferred: a DEVELOPER holding no grant on any connection read literal-bearing slow-query SQL with real customer ids and names (/slow-query-analytics/{id}/query/{fp}/samples→ 200 while/slow-log-source/{id}→ 403 in the same session), enumerated another tenant's schema, and deleted that tenant's analysis history viaDELETE /slow-queries/history/connection/{id}. All 116 are now guarded.ConnectionScopedAuthorizationSafetyTestreplaces the per-file approach: it scans every*Controller.java, so a new controller is covered the day it is written. Writing it immediately found 9 more unguarded endpoints in controllers nobody was looking at, includingStatsController,ProjectController,DashboardControllerand a destructiveDELETE /sentinel/demo/cleanup/{connectionId}. - Two endpoints decrypted another user's credentials before anyone checked access.
GET /slow-query-analytics/{id}/tenant-column-suggestionsand/configreachsuggestTenantColumns→getJdbcTemplateForBackgroundJob→credentialService.getDecryptedConnection, opening a live JDBC session to the target database. An unguarded read is not only a data leak; it can be a credential-use primitive. Check before the work, not after. - A path-variable sweep is not enough — ids in the request body need their own
check. Four holes survived exactly that kind of fix:
snapshots/compare(two snapshot ids, noconnectionIdat all — it would diff tenant A's schema against tenant B's),PUT /performance-actions/batch-status(an arbitraryactionIdslist, no scope), andchanges/acknowledge/regressions/acknowledge(path connection authorized, body ids unchecked).allChangesBelongTo/allComparisonsBelongToverify membership, and an id that resolves to nothing fails too — otherwise unknown ids can be mixed into an otherwise valid batch. The safety test has a dedicated case for body-supplied id collections. - An id is not a capability. For
alertId,actionId,regressionId,recommendationId,fingerprintId,planId,ruleId,snapshotId,historyId: resolve the owning connection and assert on that. Several services had no such accessor, sofindConnectionIdFor*was added toQueryPerformanceService,QueryPlanCacheService,SentinelAnalyticsService,BusinessRuleMemoryService,SlowQueryAlertService,QueryFingerprintServiceandSchemaChangeTrackingService. These helpers report 404 for both "no such id" and "not yours", viaassertCanRead/ManageConnectionContentOrNotFound. The first attempt only 404'd the unknown case and left an authorized-but-denied row at 403, which still confirms the row exists — a review caught that the code comments claimed a property the code did not have.query_performance_regression.idis a sequentialLong, so walking 1..N would have mapped every tenant's regressions. Same answerDashboardWorkspaceService.assertCanReadDashboardalready gives. Endpoints keyed on a connectionId keep 403: the caller already knows that connection exists, so an actionable "access denied" is better than a misleading 404. - Never take the actor from the request.
POST /slow-queries/alerts/{id}/acknowledgetook@RequestParam String userId;acknowledgedBydefaulted to the literal string"user";resolvedBy,updatedByand Sentinel'sinitiatedBycame from the request body — so the audit trail was unauthenticated free text and could name any colleague. All of them now useaccessControlService.requireCurrentUsername(). The parameters are still accepted (wire compatibility) and ignored, which is noted at each site so nobody re-wires them. - Guarded vs unguarded is an existence oracle. A guarded endpoint 404s an unknown
connection id (
resolveCurrentUserAccesswraps the lookup); an unguarded one returned 200. That difference alone enumerated valid connection ids. - A scanner built on an allowlist of id names can only catch the ids someone
remembered.
ConnectionScopedAuthorizationSafetyTestfirst matchedbody.contains("connectionId")plus a hand-written list (alertId|actionId|regressionId|…). Both halves leaked:ProjectController.createProjectreadsrequest.getConnectionId()— capital C — andprojectIdwas not in the list, soPOST /projectsandGET|PUT|DELETE /projects/{projectId}were invisible while the suite reported every case green. Now the connection match is case-insensitive and any@PathVariable …Idcounts as connection-owned until proven otherwise, with genuine exceptions inNOT_CONNECTION_OWNED_IDScarrying a reason. Inverting it immediately surfaced fourPlaybookControllerendpoints — those turned out to be true negatives (Playbookhas noconnectionId; playbooks are global templates), andplaybookExemptionHoldsOnlyWhilePlaybooksAreConnectionFreefails the build if aconnectionIdis ever added to that entity. A safety test that reports green is evidence only about what it can see. - Two path variables are as dangerous as a body id.
POST /schema-changes/{connectionId}/snapshots/{snapshotId}/set-baselineauthorized the connection and then flipped whatever snapshot id it was handed to BASELINE and pointed that connection's drift config at it — so manage access on A could retarget B's snapshot and bind A's baseline to it.setBaselinenow refuses a snapshot whoseconnectionIddiffers, in the service as well as the controller, and throws rather than silently skipping: no-op'ing the snapshot write while still writing the drift config would leave the config pointing at another connection's snapshot. When a handler takes an id alongside aconnectionId, authorizing the connection is half the check. - A
@ControllerAdvicecatch-all swallows a 403 the same way an in-method one does, and it is easier to miss because it lives in another file.IndexAdvisorExceptionHandlerhas@ExceptionHandler(Exception.class), so the newly added guard on/index-advisor/{id}/health-reportreturned500 "Index operation failed"with the 403's text in the body — the denial held, but the response blamed the index store. Found by testing the fix, not by reading it: the other 24 endpoints returned 403 and this one did not. It now has an@ExceptionHandler(ResponseStatusException.class)that preserves the status, andConnectionScopedAuthorizationSafetyTestasserts every advice with a catch-all also handlesResponseStatusException. @CrossOrigin(origins = "*")on a controller is dead code here, and worth deleting.SentinelAnalyticsControllercarried it. Tested: an evil-origin preflight gets403with noAccess-Control-Allow-Origin(theSecurityConfigallowlist wins), while an allowed origin gets200+ ACAO — so the annotation never had effect. It still reads like an intentional hole to the next person.
Whenever you add, rename, or remove an MCP tool or a CLI subcommand, you MUST update all of these in the same commit — they are agent-facing surfaces and drift silently breaks discoverability:
- MCP tool definition —
mcp/deepsql-phase1-lib.js(TOOL_DEFINITIONS+handleToolCallcase +buildToolResultcase + asummarize*function for the human-readable summary). - CLI dispatcher —
mcp/src/commands/<command>.js(SUBCOMMANDSmap + handler function). - CLI help text —
mcp/src/cli.js(COMMAND_HELP[<command>].subcommandsand.options). The drift guard inmcp/src/cli.test.jswill fail the build ifSUBCOMMANDS≠ documented subcommands. If you add a new command file, extendHELP_DRIFT_TARGETSin that test. - Agent skill body —
mcp/skills/SKILL_BODY.md(the MCP tools table and the CLI catalog table). This is what every agent with the DeepSQL skill loaded actually reads. Bump the tool count at the top if it changed. - Package docs —
mcp/CLAUDE.md(full tool table) andmcp/README.md(the npm landing page tool table). - npm version bump —
mcp/package.json: minor (0.X.0) for new tools/commands, patch (0.X.Y) for fixes. Then runnpm publish(requires OTP from authenticator).
After every publish, sanity-check by reinstalling globally and running deepsql <command> -h on the changed command — the help output is the user's source of truth and must match what's dispatchable.
None of these carry a baked-in default in application*.properties anymore
(SelfHostPropertiesSafetyTest enforces this). The database, JWT, and encryption
values must be exported before mvn spring-boot:run, even for local dev against the
docker-compose Postgres — without them the backend will not start. The LLM and vector
store values are needed to use those features, not to boot; see the comments inline.
DB_URL=jdbc:postgresql://localhost:5432/dba_agent
DB_USERNAME=postgres
# Must match docker-compose.yml's postgres service (POSTGRES_PASSWORD/DB_PASSWORD),
# which itself defaults to "postgres" only inside the compose network — the bare
# `mvn spring-boot:run` path (no compose) always needs this exported explicitly.
DB_PASSWORD=postgres
SECURITY_JWT_SECRET=<secret>
# LLM — read by LlmConfigResolver. PROVIDER gates the rest: with it unset, no other
# DEEPSQL_CHAT_*/DEEPSQL_EMBEDDING_* value is read. `openai` is the only provider id
# shipped and covers OpenAI, Azure OpenAI, and any OpenAI-compatible server.
# Chat ENDPOINT has no working fallback — set it explicitly. Embedding MODEL and
# ENDPOINT do default (text-embedding-3-large, https://api.openai.com/v1).
# Nothing here is needed to *boot*; the backend starts unconfigured and throws
# LlmNotConfiguredException at call time.
DEEPSQL_CHAT_PROVIDER=openai
DEEPSQL_CHAT_API_KEY=<key>
DEEPSQL_CHAT_ENDPOINT=https://api.openai.com/v1
DEEPSQL_CHAT_MODEL=gpt-4o
DEEPSQL_EMBEDDING_PROVIDER=openai
DEEPSQL_EMBEDDING_API_KEY=<key>
DEEPSQL_EMBEDDING_MODEL=text-embedding-3-large
# Optional chat tuning: DEEPSQL_CHAT_TEMPERATURE, DEEPSQL_CHAT_API_VERSION,
# DEEPSQL_CHAT_USE_RESPONSES_API (true|false|auto).
# Only if using Azure AI Search instead of pgvector for the vector store.
azure.search.api-key=<key>
azure.search.endpoint=https://<resource>.search.windows.net
# /api/llm/v1 — the OpenAI-shaped gateway the DeepSQL CLI agent points at — needs no
# variables of its own. LlmProxyController resolves through LlmConfigResolver.resolveChat()
# and picks its auth header with OpenAiEndpoints.isAzure(endpoint), so the DEEPSQL_CHAT_*
# bundle above configures it too; unconfigured, it returns 503 naming those variables.
# AZURE_OPENAI_* is now read by no code at all.
# Encryption key(s) for the credential vault. EncryptionService requires ENCRYPTION_KEY
# or ENCRYPTION_KEYS to be set — with neither, the backend fails to start
# (IllegalStateException: "Missing encryption key; set ENCRYPTION_KEY or ENCRYPTION_KEYS").
# Single-key form (simplest for local dev/self-host):
ENCRYPTION_KEY=<32-byte-base64-key> # generate with: openssl rand -base64 32
# Multi-key form (supports rotation; "id:key" pairs, comma-separated; the active one
# is selected by ENCRYPTION_KEY_ID). Used by docker-compose.yml's backend service:
ENCRYPTION_KEYS=<id-1>:<32-byte-base64-key-1>,<id-2>:<32-byte-base64-key-2>
ENCRYPTION_KEY_ID=<id-1>-
Backend: 143 tests, ~31 min.
mvn testfrombackend/. -
Integration tests: Require
TEST_CONNECTION_IDinapplication-test.properties. Four more requirements are not optional, and each fails in a way that points somewhere else entirely — all four were diagnosed the hard way:ENCRYPTION_KEYSmust contain the key id the app used when it saved the connections, not just the oneapplication-test.propertiespins (ENCRYPTION_KEY_ID=local-2025-01). The id is embedded in each ciphertext envelope, so a test JVM that knows only a different id cannot decrypt any stored credential. Symptom: dozens ofNo encryption key configured for id: <id>, surfacing to the caller as "DeepSQL can't access this database connection right now". Pass both:ENCRYPTION_KEYS=local-2025-01:$KEY,<app-key-id>:$KEY.- A reachable Redis/Valkey. Redis failure is graceful for caching, but not on
this path — with nothing at
localhost:6379the connection-access lookup fails and reports itself as a database-connectivity problem. SetSPRING_DATA_REDIS_HOST. - LLM credentials (
DEEPSQL_CHAT_*). Chat integration tests make real model calls; without them the agent runtime fails and every chat assertion reports "the agent runtime hit an internal execution failure". Note this costs real tokens. - Stop the running backend first. It and the test JVM open the same
dba_agentdatabase withddl-auto=update. The test JVM'sALTER TABLEneeds ACCESS EXCLUSIVE on a table the live app is inserting into, every later insert queues behind the pending ALTER, and the suite hangs indefinitely with no error — observed as a 42-minute stall onrag_documents. Rundocker compose stop backendfirst.
-
application-test.propertiesno longer bakes in credentials either (sameSelfHostPropertiesSafetyTestguard scans it). Any test that boots the full Spring context under@ActiveProfiles("test")(e.g.ApiSmokeTest) requiresTEST_DB_PASSWORD,ENCRYPTION_KEYS, andAZURE_SEARCH_API_KEYto be exported.ENCRYPTION_KEYSmust use the idlocal-2025-01—application-test.propertiespinsENCRYPTION_KEY_IDto it. No LLM credential is needed to boot the context: the eager Azure OpenAI client bean is gone (AzureOpenAIConfig,OpenAISdkConfigandResponsesApiConfigwere all deleted), and providers now resolve credentials per call. Verified green withAZURE_OPENAI_KEYunset:cd backend && TEST_DB_PASSWORD=postgres \ ENCRYPTION_KEYS=local-2025-01:$(openssl rand -base64 32) \ AZURE_SEARCH_API_KEY=dummy-test-key \ mvn test -Dtest=ApiSmokeTest
-
Frontend:
npm run lintfor static analysis. -
Quick local deploy regression:
npm run test:local-regressionruns the frontend build, service health probes, and the backendApiSmokeTest. Enable frontend lint explicitly withLOCAL_REGRESSION_RUN_FRONTEND_LINT=1.
After completing any significant task, update this file and/or docs/root/CLAUDE.md to reflect changes (new services, APIs, behaviors, config, bug fixes, anti-patterns).