91 KiB
Refactoring & Security Plan — adiuvAI Workspace
Generated: 2026-06-12 · Tree state: main @ 315c5d0 (clean). Line numbers reference this state — execute file-split refactors (QUAL-*) last, they invalidate line references.
Scope: adiuvAI/ (Electron), api/ (FastAPI), waitlist/, website/.
Method: 8 parallel read-only audit agents (security ×2, dead-code/deps ×2, perf/correctness ×2, quality/types, waitlist/website), tool-assisted (knip, npm audit, ruff, tsc --noEmit, import-graph). Every finding verified against actual code.
Legend: ⚠️ REVIEW = needs human or strong-model validation before execution. Do NOT hand these to a low-capability executor unattended.
Summary Table
| ID | Category | Severity | File | Description |
|---|---|---|---|---|
| SEC-01 | Security | Critical | adiuvAI/src/main/api/drizzle-executor.ts | Backend can read ANY file on user's disk via generic FS tool handlers |
| SEC-02 | Security | High | api/app/api/middleware/rate_limit.py | Zero rate limiting on login/register/refresh (unauthenticated bypass) |
| SEC-03 | Security | High | api/app/models.py | Per-user Fernet key stored plaintext beside the ciphertext it protects |
| SEC-04 | Security | High | api/app/core/deep_agent.py +3 | Raw user content/PII sent to Langfuse (GDPR / L.132/2025) |
| SEC-05 | Security | High | adiuvAI/src/main/api/drizzle-executor.ts | Mass assignment: backend sets arbitrary columns on insert/update |
| SEC-06 | Security | High | adiuvAI/src/main/index.ts | No will-navigate / setWindowOpenHandler guards on main window |
| SEC-07 | Security | High | adiuvAI (app-wide) | No Content-Security-Policy |
| SEC-08 | Security | High | waitlist/app/rate_limit.py | Rate limit keyed on spoofable CF-Connecting-IP / X-Forwarded-For |
| SEC-09 | Security | High | waitlist/app/config.py | CONFIRM_SECRET defaults to per-process random → broken across workers |
| SEC-10 | Security | Medium | api/app/api/routes/auth.py | Login 500-crashes for social-only accounts (account-type oracle) |
| SEC-11 | Security | Medium | api/app/api/routes/auth.py | No password strength validation on register |
| SEC-12 | Security | Medium | api/app/api/routes/auth.py | No logout endpoint / refresh-token revocation |
| SEC-13 | Security | Medium | api + adiuvAI (WS handshake) | JWT in WebSocket URL query string (both sides) |
| SEC-14 | Security | Medium | api/app/config/settings.py | Insecure defaults (JWT_SECRET) with no prod startup guard |
| SEC-15 | Security | Medium | api (device_ws, scout_runner, deep_agent) | User message content in INFO logs (GDPR) |
| SEC-16 | Security | Medium | api/app/models.py | Relational-memory entity labels stored plaintext (third-party PII) |
| SEC-17 | Security | Medium | api/app/api/middleware/rate_limit.py | In-memory limiter state → ×4 bypass under gunicorn -w 4 |
| SEC-18 | Security | Medium | api (auth.py, scouts.py) | In-memory OAuth state stores break multi-worker; strand Gmail tokens |
| SEC-19 | Security | Medium | api/app/api/routes/scout_webhooks.py | Gmail Pub/Sub webhook fail-open when audience unset |
| SEC-20 | Security | Medium | adiuvAI/src/main/ipc.ts | IPC bridge doesn't validate sender; all procedures public |
| SEC-21 | Security | Medium | adiuvAI/src/main/ai/token.ts | Plaintext token + backup-key fallback in electron-store |
| SEC-22 | Security | Medium | adiuvAI/src/renderer/lib/httpLink.ts | Web SPA keeps JWT in localStorage |
| SEC-23 | Security | Medium | waitlist/app/security.py | Origin validation bypass via startswith |
| SEC-24 | Security | Medium | waitlist/app/routes.py | GDPR erasure + confirm triggered by bare GET (mail scanners) |
| SEC-25 | Security | Medium | waitlist/app/routes.py | Unauthenticated email re-send = mail-bombing vector |
| SEC-26 | Security | Medium | website/index.html | Third-party scripts without SRI; lucide@latest from unpkg |
| SEC-27 | Security | Medium | api/app/api/routes/device_ws.py | _index_sessions: no ownership check + never purged on disconnect |
| SEC-28 | Security | Medium | adiuvAI (db, attachments) | Local SQLite + attachments unencrypted at rest (product decision) |
| SEC-29 | Security | Low | api/app/api/routes/auth.py | Email enumeration via register 409 |
| SEC-30 | Security | Low | api/app/api/routes/device_ws.py | Internal exception strings returned to WS clients |
| SEC-31 | Security | Low | adiuvAI/src/main/router/index.ts | attachments.create accepts arbitrary sourcePath |
| SEC-32 | Security | Low | adiuvAI/src/main/index.ts | Scout Gmail deep link forwarded without local state check |
| SEC-33 | Security | Low | waitlist/app/routes.py | Partial email fragments in logs |
| SEC-34 | Security | Low | website (all pages) | No CSP / security headers on static site |
| SEC-35 | Security | Low | website/i18n.js | innerHTML i18n sink (latent XSS) |
| SEC-36 | Security | Low | adiuvAI/src/main/index.ts | sandbox not explicitly enabled in webPreferences |
| CORR-01 | Correctness | Critical | api/app/api/routes/device_ws.py | Chat tool calls: no timeout + send-before-register race → agent hangs forever |
| CORR-02 | Correctness | High | api/app/core/device_manager.py | WS unregister clobbers a freshly reconnected connection |
| CORR-03 | Correctness | High | api/app/core/memory_maintenance.py | Free-tier extraction drains with empty content — feature is a paid no-op |
| CORR-04 | Correctness | High | adiuvAI (renderer chat contexts) | Backend errors returned as success payloads ignored → chat wedged in "streaming" + listener leaks |
| CORR-05 | Correctness | High | adiuvAI/src/main/router/index.ts + drizzle-executor.ts | Cascade-delete gaps, zero transactions; AI-issued deletes orphan rows + disk files |
| CORR-06 | Correctness | High | adiuvAI (backend-client.ts + files/indexer.ts) | WS drop mid-index permanently wedges project in 'scanning' |
| CORR-07 | Correctness | Medium | adiuvAI/src/main/api/backend-client.ts | WS close handler clobbers newly opened connection (logout→login race) |
| CORR-08 | Correctness | Medium | api/app/core/memory_maintenance.py | Confidence decay applied per invocation, not per period |
| CORR-09 | Correctness | Medium | api (models + memory_middleware) | Check-then-insert races: memory_core / memory_relations lack unique constraints |
| CORR-10 | Correctness | Medium | api/app/api/routes/device_ws.py | Fire-and-forget create_task: no refs, no per-user concurrency cap |
| CORR-11 | Correctness | Medium | adiuvAI/src/main/router/index.ts | tasks.update unconditionally destroys briefing + chat history |
| CORR-12 | Correctness | Medium | adiuvAI/src/main/ai/orchestrator.ts | Stream errors emitted as plain stream_end → blank persisted messages |
| CORR-13 | Correctness | Medium | adiuvAI/src/main/api/backend-client.ts | withRetry retries non-idempotent POSTs (duplicate scout runs) |
| CORR-14 | Correctness | Medium | api/app/billing/stripe_service.py | Sync Stripe SDK calls block the event loop |
| CORR-15 | Correctness | Medium | api/app/api/routes/device_ws.py | run_home_stream swallows exceptions; client never gets error stream_end |
| CORR-16 | Correctness | Medium | api/app/api/routes/auth.py | delete_account: Stripe commit then DB deletes, broad except pass |
| CORR-17 | Correctness | Medium | adiuvAI/src/main/files + projectFolders.ts | Index-session TOCTOU + stale _active entry |
| CORR-18 | Correctness | Low | api/app/api/routes/scouts.py | Scout trigger TOCTOU → duplicate concurrent runs |
| CORR-19 | Correctness | Low | adiuvAI (router, drizzle-executor, auth-manager) | Swallowed-error triage: silent [] returns, bare catches, unguarded JSON.parse |
| CORR-20 | Correctness | Low | api/app/api/routes/auth.py | Naive/aware datetime kludge on refresh expiry |
| CORR-21 | Correctness | Low | adiuvAI (misc) | LanguageSync inside App body; fake AbortSignal; startsWith containment |
| PERF-01 | Performance | High | api/app/api/routes/auth.py | bcrypt runs on the event loop (rate-limit-exempt login path) |
| PERF-02 | Performance | High | api/app/core/deep_agent.py | Home channel buffers ALL tokens — streaming endpoint doesn't stream |
| PERF-03 | Performance | High | api/app/core/memory_maintenance.py | Hourly mining duplicates proactive rows; _load_proactive has no LIMIT |
| PERF-04 | Performance | Medium | adiuvAI/src/main/db/schema.ts | No SQLite indexes on any user-data table (sync full scans block IPC) |
| PERF-05 | Performance | Medium | api/app/models.py | Missing PG composite/pgvector indexes; refresh_tokens never purged |
| PERF-06 | Performance | Medium | adiuvAI (orchestrator + ChatSurface) | Per-token IPC message + full markdown re-parse per chunk |
| PERF-07 | Performance | Medium | api/app/core/deep_agent.py | Tool calls executed serially per LLM step (each a WS round-trip) |
| PERF-08 | Performance | Medium | api (deep_agent, scout_runner, langfuse_client) | Blocking lf.flush() per request; sync get_prompt on cache miss |
| PERF-09 | Performance | Medium | api/app/api/middleware/auth.py | get_current_user: 3 queries + full Fernet decrypt on EVERY request |
| PERF-10 | Performance | Medium | api/app/core/memory_middleware.py | Same User row re-fetched up to 4×/message; update_core commits per key |
| PERF-11 | Performance | Medium | adiuvAI/src/main/ai/orchestrator.ts | Health-check HTTP round-trip before every chat message |
| PERF-12 | Performance | Medium | adiuvAI/src/main/router/index.ts | Paid LLM brief regeneration fired by every week-relevant mutation |
| PERF-13 | Performance | Medium | adiuvAI (vite configs, ChatChartBlock, notes route) | No route code-splitting; recharts + Milkdown eager in initial bundle |
| PERF-14 | Performance | Medium | adiuvAI/src/renderer/index.tsx | Default QueryClient → refetch storms into sync SQLite on window focus |
| PERF-15 | Performance | Medium | adiuvAI (projectFolders.ts + indexer.ts) | Double full-folder walk; no WS backpressure (memory spike) |
| PERF-16 | Performance | Medium | api (scouts/engine.py, device_ws.py) | DB sessions held open across LLM calls (pool starvation) |
| PERF-17 | Performance | Medium | api/app/core/scout_runner.py | Per-file get_file_metadata WS round-trip during scans |
| PERF-18 | Performance | Low | api (embeddings.py, llm.py) | Fresh AsyncOpenAI client per call, never closed |
| PERF-19 | Performance | Low | adiuvAI/src/renderer/components/tasks/TaskListView.tsx | tasks.list returns all rows; pagination client-side |
| PERF-20 | Performance | Low | api/app/api/middleware/rate_limit.py | Limiter dict never sheds keys; tier read from JWT not DB |
| PERF-21 | Performance | Low | adiuvAI/src/main/api/drizzle-executor.ts | Unbounded page-details tables; uncapped pdf/docx base64 reads |
| PERF-22 | Performance | Low | adiuvAI/src/renderer/context | HeaderContext unmemoized value; ContextualChat re-renders route per token |
| DEAD-01 | Dead code | High | api/requirements.txt | 5 dead deps: pinecone, qdrant-client, boto3, moto[s3], google-auth-oauthlib |
| DEAD-02 | Dead code | High | api/app/core/scout_registry.py | Fully orphaned module (BaseAgent, zero importers) |
| DEAD-03 | Dead code | Medium | api/app/api/middleware/rate_limit.py | slowapi Limiter exported, zero decorated routes — dep removable |
| DEAD-04 | Dead code | Medium | api/app/api/routes/chat.py | HTTP /chat routes unused by Electron client (WS is the channel) |
| DEAD-05 | Dead code | Medium | adiuvAI/package.json | 4 unused deps: next-themes, mammoth, pdf-parse, @hello-pangea/dnd |
| DEAD-06 | Dead code | Medium | adiuvAI/src (5 files) | Dead modules: batch-types, useChatStream, useTaskBriefCache, ScoutRunLog, blocks barrel |
| DEAD-07 | Dead code | Medium | adiuvAI/src/main/auth/backup-key.ts | Documented architecture but zero importers — wire or remove |
| DEAD-08 | Dead code | Medium | adiuvAI/knip.json | Missing web-SPA entries → knip false-flags live files |
| DEAD-09 | Dead code | Medium | api/requirements.txt | langchain meta-package → langchain-core; redundant websockets pin |
| DEAD-10 | Dead code | Low | api (13 files) | 13 ruff F401 unused imports (auto-fixable) |
| DEAD-11 | Dead code | Low | adiuvAI/src (7 exports) | Dead utility exports (getRawSqlite, parseDateRange, formatTime, …) |
| DEAD-12 | Dead code | Low | api/requirements.txt + backend-client.ts | Dev deps in runtime requirements; stale endpoint doc comments |
| DEPS-01 | Dependencies | High | adiuvAI/package.json | ws 8.19.0 vulnerable (GHSA-58qx-3vcg-4xpx) — only runtime-reachable vuln |
| DEPS-02 | Dependencies | High | api/requirements.txt | python-jose floor 3.3.0 permits CVE-2024-33663/33664 versions |
| DEPS-03 | Dependencies | Medium | api/requirements.txt | cryptography floor 42.0.0 permits CVE-2024-26130 versions |
| DEPS-04 | Dependencies | Medium | api/ | No lock file — >= floors make installs unauditable |
| DEPS-05 | Dependencies | Medium | adiuvAI/package.json | eslint 8 + @typescript-eslint 5 both EOL |
| DEPS-06 | Dependencies | Medium | adiuvAI/package.json | Electron 40 → 42 (two majors of Chromium security patches behind) |
| DEPS-07 | Dependencies | Low | adiuvAI (transitive) | tmp/tar/esbuild advisories in forge/drizzle-kit toolchain — no fix, monitor |
| DEPS-08 | Dependencies | Low | adiuvAI/package.json | @types/ws in dependencies instead of devDependencies |
| QUAL-01 | Quality | High | adiuvAI/src/main/router/index.ts | God file: 1967 LOC, 15 sub-routers — split per domain |
| QUAL-02 | Quality | High | api/app/core/deep_agent.py | _run_single_agent vs _run_single_agent_stream ~90% duplicated |
| QUAL-03 | Quality | High | api (4 files) | Four hand-rolled LLM tool loops — extract one |
| QUAL-04 | Quality | High | adiuvAI/src/main/router/index.ts | CRUD repetition ×8 tables — extract factory |
| QUAL-05 | Quality | High | api/app/core/deep_agent.py | God file: 1329 LOC — split prompt-context / tools / runner |
| QUAL-06 | Quality | Medium | adiuvAI/src/main/api/backend-client.ts | God file 1191 LOC; 230-line openDeviceWebSocket; send* boilerplate ×6 |
| QUAL-07 | Quality | Medium | api/app/core/scout_runner.py | 1051 LOC; run_local_agent/run_cloud_agent 210-line twins |
| QUAL-08 | Quality | Medium | api/app/api/routes/device_ws.py | Protocol + business logic mixed — extract ws_handlers package |
| QUAL-09 | Quality | Medium | adiuvAI/src/renderer/components/projects/ProjectSidebar.tsx | 1292 LOC, 25+ useState — extract dialogs |
| QUAL-10 | Quality | Medium | api/app/api/routes/scouts.py | Ownership-check + 404 repeated 6× — FastAPI dependency |
| QUAL-11 | Quality | Medium | adiuvAI/src/main/router/index.ts | Three competing error-return styles, zero TRPCError |
| QUAL-12 | Quality | Medium | api/app/api/routes/device_ws.py | Raw-dict frames bypass pydantic schemas (also kills 7 type:ignores) |
| QUAL-13 | Quality | Medium | api + adiuvAI (WS boundary) | camelCase/snake_case contract untrusted (dual-read hedges) |
| QUAL-14 | Quality | Medium | api (auth.py, scouts.py) | OAuth route blocks → dedicated files; shared TTLStateStore |
| QUAL-15 | Quality | Low | api/app/agents | Copy-pasted _is_uuid + row formatting across agents |
| TYPE-01 | Type safety | High | adiuvAI/src/renderer/components/ai/blocks (3 files) | Broken relative imports — block types silently any |
| TYPE-02 | Type safety | High | api/ | No Python type checker configured at all |
| TYPE-03 | Type safety | Medium | adiuvAI/ | tsc --noEmit does not pass; no CI typecheck |
| TYPE-04 | Type safety | Medium | api/app/core/deep_agent.py | context: dict[str, Any] threaded everywhere — RequestContext model |
| TYPE-05 | Type safety | Medium | adiuvAI/src/main/api/drizzle-executor.ts | Cast cluster: table-as-Record ×5, bogus Boolean predicate casts |
| TYPE-06 | Type safety | Low | adiuvAI/src/renderer | (window as any).electronAI ×4 — global declaration file |
| TYPE-07 | Type safety | Low | api + adiuvAI | Misc: ToolResult TypedDict, 11 missing py annotations, vite/client types |
Execution Order
Line numbers reference the current tree — do mechanical fixes before file splits.
Phase 0 — Mechanical quick wins (safe for low-capability executor, no review needed): TYPE-01 → DEPS-01 → DEAD-10 → DEAD-01 → DEAD-02 → DEAD-05 → DEAD-08 → DEPS-08 → DEAD-11 → DEAD-12 → PERF-14 → PERF-18 → CORR-20
Phase 1 — Critical & High security (ALL ⚠️ REVIEW, human validates each diff): SEC-01 → SEC-05 → SEC-06 → SEC-07 → SEC-20 (these four+one harden the Electron trust boundary together) → SEC-02 → SEC-14 → SEC-19 (share a startup-guard mechanism: do SEC-14 first) → SEC-09 → SEC-08 → SEC-04 → SEC-15 → SEC-03 (needs migration design)
Phase 2 — Critical & High correctness: CORR-01 → CORR-02 (same files, same review session) → CORR-04 → CORR-12 (CORR-04 depends on CORR-12's error-frame contract — implement together) → CORR-06 → CORR-05 ⚠️ → CORR-03
Phase 3 — Remaining Medium security + correctness: SEC-10 → SEC-11 → SEC-12 → SEC-13 (coordinated api+electron change) → SEC-17/SEC-18 (one Redis introduction covers both) → SEC-27 → SEC-21 → remaining SEC Mediums → CORR-07..CORR-17
Phase 4 — Performance: PERF-01 → PERF-02 ⚠️ → PERF-03 → PERF-04 → PERF-05 (migrations) → PERF-09/PERF-10 (same subsystem) → PERF-06 → rest
Phase 5 — Dependencies & tooling: DEPS-02 → DEPS-03 → DEPS-04 → TYPE-02 → TYPE-03 → DEPS-05 ⚠️ → DEPS-06 ⚠️
Phase 6 — Structural refactors (after all above; invalidates line numbers): QUAL-02 ⚠️ → QUAL-03 ⚠️ → QUAL-01 → QUAL-04 ⚠️ → QUAL-05 → QUAL-06..QUAL-15 → TYPE-04..TYPE-07 → DEAD-04 ⚠️ / DEAD-07 ⚠️ (owner decisions)
1. Security
Per instruction: every SEC item is ⚠️ REVIEW unless explicitly noted "mechanical". A botched auth check or sanitizer is worse than the original bug.
SEC-01 — Backend can read ANY file on the user's disk ⚠️ REVIEW
- File:
adiuvAI/src/main/api/drizzle-executor.ts—handleListDirectoryL375-398,handleReadFileContentL400-436,handleGetFileMetadataL438-461 - Severity: Critical · OWASP A01:2021 Broken Access Control
- Problem: These handlers take a backend-supplied
path, resolve it (fs.promises.realpath(path.resolve(dirPath))) and read it with no allowlist or root containment.read_file_contentreturns up to 500 KB of any file (~/.ssh/id_rsa, browser cookie DBs). The backend is semi-trusted by design; if compromised, blast radius = the entire user filesystem. The backend adds no guard either (api/app/agents/filesystem_agent.py:24-33passes absolute paths straight through). Contrast:handleReadProjectFolderFile(L511-529) does containment correctly. - Fix: After realpath, assert the resolved path is inside an allowed root: the set of configured
projects.folderPathvalues (query the projects table) plus nothing else. Useconst rel = path.relative(root, resolved); if (rel.startsWith('..') || path.isAbsolute(rel)) throw new ExecutorError('Access denied'). Apply identically in all three handlers. Also fix the existingstartsWithcontainment inhandleReadProjectFolderFileL527 to use the samepath.relativecheck (defeatsC:\proj\foo-evilvsC:\proj\foo). - Risk: Local filesystem scouts or agent flows that legitimately read outside project folders will break — verify which directories local scout configs reference and add those roots to the allowlist before locking down. Test: agent file-read inside project folder still works; read of
C:\Windows\win.inirejected.
SEC-02 — No rate limiting on login/register/refresh ⚠️ REVIEW
- File:
api/app/api/middleware/rate_limit.py:41-48(_EXEMPT_PATHS),:88-91(no-token pass-through) - Severity: High · OWASP A07:2021 Identification & Authentication Failures
- Problem: Limiter only acts on requests with a valid Bearer JWT;
if not token: return await call_next(request). Login/register/refresh are unauthenticated → never rate-limited. Unlimited password guessing and refresh-token guessing. - Fix: Remove
/api/v1/auth/loginand/api/v1/auth/registerfrom_EXEMPT_PATHS. Indispatch(), when no token is present andrequest.url.path.startswith("/api/v1/auth/"), apply an IP-keyed sliding window (reuse the existing_windowmechanism keyed on client IP, fixed limit e.g. 5/min for login/register/refresh, 429 on excess). Note this remains per-process until SEC-17 lands Redis. - Risk: Shared-NAT users may hit limits; size window accordingly. Test: 6th login attempt in a minute → 429; authenticated traffic unaffected.
SEC-03 — Fernet key stored in plaintext beside its ciphertext ⚠️ REVIEW
- File:
api/app/models.py:83(User.encryption_key), read atapi/app/core/memory_middleware.py:559-566 - Severity: High · OWASP A02:2021 Cryptographic Failures
- Problem: The per-user key that encrypts all memory tiers sits as a plaintext column in the same PostgreSQL DB. Any DB-level compromise or backup leak decrypts everything — the "encrypted at rest" zero-trust claim collapses.
- Fix: Introduce a KEK from env/KMS (model: the existing
OAUTH_ENCRYPTION_KEYpattern inapi/app/integrations/__init__.py:90-102). StoreFernet(KEK).encrypt(user_key)in the column; unwrap in_get_fernet. One-time Alembic data migration re-wrapping existing keys; keep a dual-read window (try unwrap, fall back to raw) during rollout, then remove fallback. - Risk: Migration must be reversible and tested against a DB snapshot; losing the KEK = losing all memory data. Requires key-rotation runbook. This is design work — human required.
SEC-04 — Raw user content & PII sent to Langfuse ⚠️ REVIEW
- Files:
api/app/core/deep_agent.py:912,926,933,975·api/app/scouts/engine.py:260-268(email subject/sender/2000-char body) ·api/app/core/scout_runner.py:266-278 - Severity: High · OWASP A09:2021 / GDPR Art. 5, 28, 44 / Italian Law 132/2025
- Problem: Full conversation history, tool I/O, and triaged email content are transmitted to Langfuse when configured.
hash_user_id()only pseudonymizes the identifier; bodies go in clear. Requires Langfuse as contracted sub-processor; possible international transfer depending onLANGFUSE_BASE_URL. - Fix: Add
LANGFUSE_CAPTURE_CONTENT: bool = Falseto settings. Wrap everyinput=/output=content assignment in the three files: when flag is false, pass{"redacted": True, "chars": len(...)}instead. Keep metadata/token counts. Document Langfuse as sub-processor in the privacy policy regardless. - Risk: Reduced debugging richness. Verify Langfuse prompt management (
get_prompt) is unaffected — it's a separate path.
SEC-05 — Mass assignment in drizzle-executor insert/update ⚠️ REVIEW
- File:
adiuvAI/src/main/api/drizzle-executor.ts—handleInsertL268-290,handleUpdateL292-327 - Severity: High · OWASP A08:2021
- Problem: Table name is allowlisted, columns are not:
{ id: crypto.randomUUID(), ...data, createdAt: now }spreads backend keys verbatim — any real column writable. Also:...datais spread afterid, so a backend-suppliedidoverrides the executor's generated one (contradicting the code's own comment). - Fix: Define a per-table writable-column allowlist (object map table→string[]). Before building
values/withTimestamp, pick only allowlisted keys; always stripidandcreatedAtfrom incomingdata/updates. Reorder insert to{ ...picked, id: crypto.randomUUID(), createdAt: now, updatedAt: now }. - Risk: Additive validation. Test every agent CRUD flow (create task/note/event, update, propose_note_edit) still works — the allowlist must cover all fields agents legitimately set.
SEC-06 — No navigation / window-open guards ⚠️ REVIEW
- File:
adiuvAI/src/main/index.ts—createWindowL85-117 (handlers absent repo-wide) - Severity: High · OWASP A05:2021
- Problem: No
setWindowOpenHandler, nowill-navigateguard. Any navigation to remote content lands in the privileged renderer (which haswindow.electronTRPC/electronAI). - Fix: In
createWindowadd:mainWindow.webContents.setWindowOpenHandler(({ url }) => { if (url.startsWith('https://')) shell.openExternal(url); return { action: 'deny' }; }); mainWindow.webContents.on('will-navigate', (e, url) => { const ok = (MAIN_WINDOW_VITE_DEV_SERVER_URL && url.startsWith(MAIN_WINDOW_VITE_DEV_SERVER_URL)) || url.startsWith('file://'); if (!ok) e.preventDefault(); }); - Risk: Minimal — app uses internal TanStack routing. Test external links in chat still open in OS browser.
SEC-07 — No Content-Security-Policy ⚠️ REVIEW
- File:
adiuvAI/index.html(no meta CSP);src/main/index.ts(no onHeadersReceived) - Severity: High · OWASP A05:2021
- Problem: Zero CSP → no defense-in-depth for any future XSS sink;
connect-srcunrestricted (compromised renderer exfiltrates anywhere). - Fix: In production only (
!IS_DEV), registersession.defaultSession.webRequest.onHeadersReceivedinjecting:default-src 'self'; script-src 'self'; connect-src 'self' https://<backend-host> wss://<backend-host>; img-src 'self' data: https:; style-src 'self' 'unsafe-inline'; object-src 'none'; frame-ancestors 'none'. Backend host from existing config. - Risk: Tailwind/shadcn need
style-src 'unsafe-inline'; Vite dev needs exemption (gate on IS_DEV). Test packaged build fully: chat, charts, Milkdown editor, images.
SEC-08 — Waitlist rate limit keyed on spoofable headers ⚠️ REVIEW
- File:
waitlist/app/rate_limit.py:19-32 - Severity: High · OWASP A04:2021 / CWE-348
- Problem:
_get_client_iptrustscf-connecting-ipthenx-forwarded-forunconditionally. Random header per request = fresh 5/min bucket → unlimited signup spam, Brevo quota burn, poisoned storedip_address. - Fix: Add
TRUSTED_PROXY_IPS: str = ""setting. Trustcf-connecting-iponly whenrequest.client.hostis in that list; otherwise userequest.client.host. Also firewall origin to Cloudflare ranges at infra level. - Risk: Misconfigured trusted-proxy list rate-limits Cloudflare itself (all users share CF egress IPs) — must be deployed together with correct env value.
SEC-09 — Waitlist CONFIRM_SECRET random default ⚠️ REVIEW
- File:
waitlist/app/config.py:19;waitlist/Dockerfile:21(-w 2) - Severity: High · OWASP A05:2021
- Problem:
CONFIRM_SECRET: str = secrets.token_hex(32)— with 2 gunicorn workers and no env var, each worker has a different HMAC secret: ~50% of confirm/unsubscribe links fail silently; all tokens die on restart. Breaks double opt-in AND the GDPR erasure path. - Fix: Remove default:
CONFIRM_SECRET: str(required field — pydantic fails startup if unset). Update.env.examplecomment. - Risk: Deploys without the env var now fail loudly at boot — that's the point. Coordinate with deploy.
SEC-10 — Login 500 for social-only accounts ⚠️ REVIEW
- File:
api/app/api/routes/auth.py:169(and_verify_passwordL73-74) - Severity: Medium · OWASP A07:2021
- Problem: OAuth-only users have
password_hash=None;hashed.encode()→ uncaughtAttributeError→ 500. Differing responses (500 vs 401) = account-type oracle + crash. - Fix: Replace the check with:
if user is None or user.password_hash is None or not _verify_password(body.password, user.password_hash): raise HTTPException(401, "Invalid credentials"). - Risk: None. Test: password login against a Google-only account → 401.
SEC-11 — No password strength on register ⚠️ REVIEW (mechanical)
- File:
api/app/api/routes/auth.py:100-104 - Severity: Medium · OWASP A07:2021
- Problem:
password: strunconstrained (1-char/empty accepted);_ChangePasswordRequestalready requiresmin_length=8.emailis plainstr, notEmailStr. - Fix: Line 102:
password: str = Field(min_length=8); changeemail: strtoemail: EmailStr(import from pydantic). Mirror what change-password already does. - Risk: None for new accounts. Existing weak-password accounts unaffected.
SEC-12 — No logout / refresh-token revocation ⚠️ REVIEW
- File:
api/app/api/routes/auth.py(endpoint absent; rotation only at L209) - Severity: Medium · OWASP A07:2021
- Problem: No server-side logout; stolen refresh token valid 30 days; no reuse detection on rotated tokens.
- Fix: Add
POST /api/v1/auth/logout(authenticated): body{refresh_token: str}, delete the matchingRefreshTokenrow (SHA-256 the input, filter by hash +user_id). Optionalall: boolto delete all the user's tokens. Wire Electronauth-manager.tslogout to call it before clearing local tokens. - Risk: Electron + backend change together. Test logout then refresh → 401.
SEC-13 — JWT in WebSocket URL query string ⚠️ REVIEW
- Files:
api/app/api/routes/device_ws.py:78·adiuvAI/src/main/api/backend-client.ts:895 - Severity: Medium · OWASP A09:2021
- Problem:
?token=<jwt>lands in reverse-proxy access logs, APM traces. Token grants full account access for 30 min. - Fix: The Electron client uses the
wspackage which supports handshake headers: pass{ headers: { Authorization: \Bearer ${token}` } }as the second arg tonew WebSocket(url, opts). Backend: readwebsocket.headers.get("authorization")first, fall back to?token=for one release, then remove fallback. Interim mitigation: strip query strings for/api/v1/ws/device` in proxy access logs. - Risk: Coordinated two-repo change; old clients break when fallback removed. Web SPA (browser WebSocket) cannot set headers — if web ever uses the WS, it needs the ticket pattern instead.
SEC-14 — Insecure config defaults, no prod guard ⚠️ REVIEW
- File:
api/app/config/settings.py:6-7;api/docker-compose.yml:22-23 - Severity: Medium · OWASP A05:2021
- Problem:
JWT_SECRET = "change-me-in-production"andpostgres:postgresdefaults; nothing refuses to start in prod with defaults → forgeable JWTs. - Fix: Add a pydantic
model_validatoronSettings: ifENV == "prod"and (JWT_SECRET == "change-me-in-production"orlen(JWT_SECRET) < 32), raiseValueErrorat import. Same guard hook used by SEC-19 (GMAIL_PUBSUB_AUDIENCEempty → fail in prod). - Risk: Prod deploys with missing env now fail at boot (intended). Dev unaffected.
SEC-15 — User message content in INFO logs ⚠️ REVIEW
- Files:
api/app/api/routes/device_ws.py:258-265,343·api/app/core/scout_runner.py:292-311·api/app/core/deep_agent.py:958-984·api/app/core/memory_middleware.py:528,554 - Severity: Medium · OWASP A09:2021 / GDPR
- Problem: Chat messages (
message[:200]), tool args/outputs ([:800]/[:1200]), memory queries logged at INFO — uncontrolled PII sink in centralized logs. - Fix: Replace content interpolations with length+ID only (e.g.
logger.info("home_request user=%s chars=%d", user_id, len(message))). Gate full content behindlogger.debug+ aLOG_CONTENT=falsesetting. - Risk: Less debuggable ops. Each call site is a one-line change; review the full grep list before executing.
SEC-16 — Relational-memory labels stored plaintext ⚠️ REVIEW
- File:
api/app/models.py:432-435; predicates atapi/app/api/routes/memory.py:34-45 - Severity: Medium · OWASP A02:2021 / GDPR
- Problem:
subject_label/object_labelhold names of user's contacts/employers (works_at,reports_to) in plaintext — third-party personal data. Onlynotes_encryptedprotected. - Fix (decision): Either encrypt labels with the user Fernet key (breaks label-match queries at
memory_middleware.py:421-428— would need deterministic encryption or hashing for lookups) or formally document in the processing record and rely on SEC-03's KEK to mitigate DB-leak. Recommend the latter + SEC-03. Human decision required. - Risk: Encrypting breaks
upsert_relationmatching — substantial rework. Don't hand to low-capability model.
SEC-17 — In-memory rate limiter ×4 bypass ⚠️ REVIEW
- File:
api/app/api/middleware/rate_limit.py:67,81 - Severity: Medium · OWASP A04:2021
- Problem: Per-process dicts under
gunicorn -w 4→ effective limit up to 4× tier limit, nondeterministic. - Fix: Back the sliding window with Redis (
INCR+EXPIREor sorted-set window) keyedrl:{user_id}:{minute}. docker-compose already has a commented Redis service — enable it. Do together with SEC-18 (one Redis introduction). - Risk: New infra dependency; need connection-failure fallback policy (fail-open vs fail-closed — recommend fail-open with warning log).
SEC-18 — In-memory OAuth state stores ⚠️ REVIEW
- Files:
api/app/api/routes/auth.py:62(_pending_states) ·api/app/api/routes/scouts.py:458,694-697(_pending_scout_oauth_states, holds encrypted Gmail token between callback and finalize) - Severity: Medium · OWASP A04:2021
- Problem: Multi-worker: authorize and callback can hit different workers → random 401s; scout flow strands the Gmail token; all state lost on redeploy.
- Fix: Move both to Redis with TTL (in-code comments already prescribe this). Implement one
TTLStateStore(see QUAL-14) withset(key, dict, ttl)/pop(key); Redis-backed in prod, dict-backed in tests. - Risk: Prereq: Redis from SEC-17. Test full OAuth login + scout Gmail connect flows.
SEC-19 — Gmail Pub/Sub webhook fail-open ⚠️ REVIEW
- File:
api/app/api/routes/scout_webhooks.py:48-52; default atapi/app/config/settings.py:69 - Severity: Medium · OWASP A05:2021/A07:2021
- Problem: Empty
GMAIL_PUBSUB_AUDIENCE→_verify_pubsub_jwtreturns True for ANY unauthenticated POST; attacker-suppliedemailAddresstriggers scout runs (LLM cost) for arbitrary users. Also unauthenticated → not rate-limited (SEC-02). - Fix: Fail closed:
if not settings.GMAIL_PUBSUB_AUDIENCE: if settings.ENV == "prod": return False; logger.warning(...); return True. Tie into SEC-14's startup guard. - Risk: Prod deploys without the env var stop receiving Gmail push (intended — they were unverified anyway).
SEC-20 — IPC bridge doesn't validate sender ⚠️ REVIEW
- Files:
adiuvAI/src/main/ipc.ts:44-87·adiuvAI/src/main/index.ts:122-131(dialog + scope handlers) - Severity: Medium · OWASP A01:2021
- Problem:
ipcMain.on('trpc', …)dispatches without checkingevent.senderFrame; all router procedures arepublicProcedure. Any frame that runs in the renderer gets full CRUD/auth/scout access. The cheap control that contains SEC-06/SEC-07 failures. - Fix: At the top of each
ipcMain.on/handlecallback: validateevent.senderFrame.urlstarts with the dev-server URL (dev) or the app'sfile://index (prod); silently drop otherwise. Extract aisTrustedSender(event)helper used by all four registration points (trpc,ai:contextual-scope-update,dialog:showOpenDialog, any others found at execution time). - Risk: None today (no sub-frames). Test dev + packaged.
SEC-21 — Plaintext token/backup-key fallback ⚠️ REVIEW
- Files:
adiuvAI/src/main/ai/token.ts:37-47·adiuvAI/src/main/auth/backup-key.ts:24-35 - Severity: Medium · OWASP A02:2021
- Problem: When
safeStorage.isEncryptionAvailable()is false (keyring-less Linux/WSL), JWTs and the AES backup key are written plaintext to electron-store JSON.readFromStorealso silently returns raw string on decrypt failure (downgrade masking). - Fix: (1) Tag values with
enc:/plain:prefix on write; on read, never attempt plaintext interpretation of anenc:value. (2) When safeStorage unavailable: keep tokens in-memory only (re-login per launch) and emit a renderer-visible warning. Never persist the backup key plaintext — fail backup-key creation instead. - Risk: Keyring-less Linux users lose session persistence. Migration: existing stored values have no prefix — treat unprefixed as legacy-plaintext, re-encrypt on first read.
SEC-22 — Web SPA token in localStorage ⚠️ REVIEW
- File:
adiuvAI/src/renderer/lib/httpLink.ts:19 - Severity: Medium (web build only) · OWASP A07:2021
- Problem:
localStorage.getItem('adiuvai-token')— readable by any XSS. - Fix (decision): httpOnly secure cookie set by backend (needs CSRF protection + CORS review) or in-memory + silent refresh. If deferring, at minimum pair with strict CSP on the web deployment. Product/infra decision — don't execute blindly.
SEC-23 — Waitlist Origin startswith bypass ⚠️ REVIEW (mechanical)
- File:
waitlist/app/security.py:50-51 - Severity: Medium · OWASP A01:2021 / CWE-346
- Problem:
origin.startswith(o)→https://adiuvai.com.evil.examplepasses. - Fix:
origin in allowedexact match; for Referer, compareurllib.parse.urlsplit(referer)._replace(path="", query="", fragment="")scheme+host against allowed origins. - Risk: None; existing tests cover the endpoint.
SEC-24 — GDPR erasure + confirm on bare GET ⚠️ REVIEW
- File:
waitlist/app/routes.py:87(confirm),:125-154(unsubscribe →_anonymize_entry) - Severity: Medium · CWE-650
- Problem: Mail scanners (SafeLinks, Mimecast) follow GETs → can anonymize a subscriber or auto-confirm a bot-planted email before any human clicks.
- Fix: Both GET handlers render a minimal HTML page with
<form method="POST">+ the token in a hidden field; move the state change to new POST handlers (POST /waitlist/confirm,POST /waitlist/unsubscribe). Keep token validation identical. - Risk: Email templates unchanged (links still GET the page). Update the 19-test suite: existing GET tests now assert the form page; add POST tests.
SEC-25 — Unauthenticated re-send mail bombing ⚠️ REVIEW
- File:
waitlist/app/routes.py:52-62 - Severity: Medium · OWASP API4:2023
- Problem: Re-POSTing a known email re-triggers
send_confirmation_email. With SEC-08's spoof bypass: mail-bomb any subscribed address via your Brevo account. - Fix: Add
last_email_sent_atcolumn (Alembic migration). In the existing-entry branch, skip the send iflast_email_sent_at< 15 min ago (still return the generic success response — preserves anti-enumeration). - Risk: Migration + test. Depends on SEC-08 for full mitigation.
SEC-26 — Website scripts without SRI; lucide@latest ⚠️ REVIEW (mechanical)
- File:
website/index.html:44-48 - Severity: Medium · OWASP A08:2021 / CWE-353
- Problem: GSAP + ScrollTrigger (cdnjs) and
lucide@latest(unpkg) loaded with nointegrity/crossorigin;@latestships whatever unpkg serves — supply-chain risk on the page hosting the PII signup form. - Fix: Pin lucide to an exact version; add
integrity="sha384-…" crossorigin="anonymous"to all three script tags (compute hashes from the pinned files), or self-host underwebsite/assets/. - Risk: Hash must match exact file; test animations + icons after.
SEC-27 — _index_sessions: no ownership check, never purged ⚠️ REVIEW
- File:
api/app/api/routes/device_ws.py:65(registry),:700(cancel),:732(batch) - Severity: Medium · OWASP A01:2021
- Problem:
_handle_index_file_batch/_handle_index_session_cancellook up bysessionIdwithout verifyingsession["user_id"] == user_id— a user who learns another's session UUID can cancel it or corrupt it. Sessions also leak forever on mid-index disconnect (no TTL, no cleanup in WS finally). - Fix: (1) In both handlers, after lookup:
if session is None or session["user_id"] != user_id: send error frame, return. (2) In the WSfinallyblock, drop all_index_sessionsentries whoseuser_id == user_idandws is websocket. - Risk: None — pure tightening. Test indexing E2E (test_ws_index_session suite exists).
SEC-28 — Local data unencrypted at rest ⚠️ REVIEW (product decision)
- Files:
adiuvAI/src/main/db/index.ts:94(plain SQLite),adiuvAI/src/main/attachments/storage.ts:17-19 - Severity: Medium (informational) · OWASP A02:2021
- Problem: All local user content (clients, projects, notes, attachments) plaintext on disk. For a data-sovereignty product, a deliberate gap.
- Fix (decision): Options: SQLCipher (better-sqlite3-multiple-ciphers fork), or document reliance on OS full-disk encryption, or encrypt attachments with the existing device backup key (DEAD-07). Roberto decides; do not execute without direction.
SEC-29 — Register email enumeration · Low ⚠️ REVIEW
- File:
api/app/api/routes/auth.py:127— 409 "Email already registered". Acceptable UX trade-off for most SaaS; mitigate via SEC-02 rate limiting. Fix: no code change now; revisit if email verification flow lands.
SEC-30 — Internal exception strings to WS clients · Low ⚠️ REVIEW (mechanical)
- File:
api/app/api/routes/device_ws.py:467-468, 503-505, 539-541, 578-580, 625-631, 653-659 - Problem:
error=str(exc)/ f-string exception text sent to renderer — can leak DB errors, paths. - Fix: Replace each with a generic message (
"Internal error"+ the request_id); keep the existing server-sidelogger.exception. 6 call sites, mechanical.
SEC-31 — attachments.create arbitrary sourcePath · Low ⚠️ REVIEW
- File:
adiuvAI/src/main/router/index.ts:1893-1915→attachments/storage.ts:25-37 - Problem: Renderer-supplied absolute path copied without restriction; defense-in-depth issue (matters only post-SEC-06/20 compromise).
- Fix: Track paths returned by the
dialog:showOpenDialoghandler in a per-sessionSet;attachments.createrejectssourcePathnot in the set. Cap file size (e.g. 100 MB). - Risk: Drag-and-drop flows (if any) bypass the dialog — verify before enforcing.
SEC-32 — Scout Gmail deep link no state check · Low ⚠️ REVIEW
- File:
adiuvAI/src/main/index.ts:46-54 - Problem:
adiuvai://scout/oauth/gmail/callbackcode+state forwarded to renderer with no pending-flow check (login OAuth path does this correctly inauth-manager.ts:321-359). Backend validates state server-side, limiting impact. - Fix: Mirror
_pendingOAuth: main process records pending Gmail-OAuth state when the flow starts;handleDeepLinkdrops callbacks whose state doesn't match.
SEC-33 — Waitlist partial email in logs · Low
- File:
waitlist/app/routes.py:75.email[:3]can reveal short local-parts. Fix: log entryidonly. Mechanical.
SEC-34 — Website missing CSP/security headers · Low ⚠️ REVIEW
- Fix: At the reverse proxy serving
website/: addContent-Security-Policy(allowlist cdnjs/unpkg or self-hosted after SEC-26),X-Content-Type-Options: nosniff,Referrer-Policy: strict-origin-when-cross-origin. Infra change, not repo code.
SEC-35 — Website innerHTML i18n sink · Low ⚠️ REVIEW (mechanical)
- File:
website/i18n.js:461-464 - Problem:
el.innerHTML = t[key]— not exploitable today (hardcoded dictionary) but latent;?lang=is user-controlled. - Fix: Use
textContentfor all keys except an explicit whitelist of the 4data-i18n-htmlkeys that genuinely contain markup; never derivet[key]content from URL/user input.
SEC-36 — sandbox not explicit · Low ⚠️ REVIEW
- File:
adiuvAI/src/main/index.ts:97-98(webPreferences) - Problem:
contextIsolation: true,nodeIntegration: falseare set (good), fuses are strong, butsandboxnot enabled. Preload only usescontextBridge/ipcRenderer, so sandbox should be compatible. - Fix: Add
sandbox: trueto webPreferences; full regression pass on packaged build (preload behavior changes under sandbox).
2. Correctness & Safety
CORR-01 — Chat tool calls: no timeout + send-before-register race ⚠️ REVIEW
- File:
api/app/api/routes/device_ws.py:238-245(_executor); contrast correct patternapi/app/core/scout_runner.py:190-198 - Severity: Critical
- Problem: (1)
await futurewith noasyncio.wait_for— if Electron never replies (renderer crash, swallowed executor exception) the agent run hangs forever. (2) Frame is sent beforecreate_pending_callregisters the future; a fasttool_resultarriving first no-ops on unknown id → permanent hang. - Fix: Reorder:
future = device_manager.create_pending_call(user_id, payload["id"])THENawait websocket.send_text(...). Wrap:return await asyncio.wait_for(future, timeout=30)withexcept asyncio.TimeoutError:→ pop the entry fromdevice_managerpending_callsand return{"error": "tool call timed out"}so the LLM loop can continue. Also pop the dict entry on timeout in the scout path (device_manager.py:145leaks it). - Risk: 30 s must exceed slowest legitimate executor op (large folder file reads) — confirm against
read_project_folder_fileworst case. Test: kill renderer mid-tool-call → agent run ends with error within 30 s.
CORR-02 — WS unregister clobbers reconnected connection ⚠️ REVIEW
- Files:
api/app/api/routes/device_ws.py:133-145·api/app/core/device_manager.py:73-81 - Severity: High
- Problem: Message loop ends silently on disconnect; heartbeat keeps the handler alive up to 30 s. If the client reconnects in that window,
register()stores the new connection, then the OLD handler'sfinallycallsunregister(user_id)— popping the new healthy connection and cancelling its pending futures. Also user looks online during the zombie window (scout runs dispatched into void). - Fix: (1)
device_manager.unregister(self, user_id, ws): only pop whenself._connections.get(user_id)andself._connections[user_id].ws is ws. Update both call sites. (2) End the heartbeat promptly: replaceasyncio.gather(loop, heartbeat)withdone, pending = await asyncio.wait({...}, return_when=FIRST_COMPLETED)then cancel pending. - Risk: Core connection lifecycle — test reconnect-within-30s, normal disconnect, heartbeat failure (test_device_ws suite exists).
CORR-03 — Free-tier extraction drains with empty content
- File:
api/app/core/memory_maintenance.py:120-154 - Severity: High
- Problem:
run_extraction(..., last_user_msg="", last_assistant_msg="", ...)—row.episode_idnever used. Every hourly tick: one wasted gpt-4o-mini call per queue row over"User: \nAssistant: ", then row deleted. Free-tier memory extraction has never worked, and it costs money. - Fix: For each row, load
MemoryEpisodicbyrow.episode_id(+user_id), decryptsummarywith the user's Fernet (reusememory_middleware._get_fernetor pass middleware instance), pass decrypted content as the extraction input. Group rows per user into a single call. Skip+delete rows whose episode is missing. - Risk: Test asserting extracted candidates reference episode content (extend test_memory_extraction). Watch cost: now real extractions run.
CORR-04 — Backend errors as success payloads ignored → chat wedged
- Files:
adiuvAI/src/main/router/index.ts:957-960(returns{response:'', error}) ·adiuvAI/src/renderer/context/ContextualChatContext.tsx:160,210-240·adiuvAI/src/renderer/components/brief/TaskBriefChat.tsx:125,144·adiuvAI/src/renderer/components/ai/AIChatPanel.tsx:313-319. Correct template:useAIChat.ts:226-256. - Severity: High
- Problem: When offline/unauthenticated, no stream frames ever arrive; the three listed call sites never check
data.erroron mutation success →isStreamingstays true forever,send()no-ops, stream listeners leak (each leaked listener receives every futureai:streamframe). - Fix: Preferred (cleaner, pre-1.0): make
aiRouter.chat/brief/research procedures throw TRPCError instead of returning{error}(ipcLink already propagates errors,ipcLink.ts:76); thenonErroris the single error path — updateuseAIChataccordingly. Each renderer call site: in the error path, call the active unsubscribe, push an error bubble, resetisStreaming/isResearching. AdduseEffectunmount cleanup forTaskBriefChat's send listener (L144). Delete deadhooks/useChatStream.ts(DEAD-06). - Risk: Touches every chat surface. Test: stop backend → send in contextual sidebar, task brief, home chat → error bubble shown, re-send works. Implement together with CORR-12 (shared error-frame contract).
CORR-05 — Cascade-delete gaps; zero transactions ⚠️ REVIEW
- Files:
adiuvAI/src/main/router/index.ts—projects.deleteL211-220,clients.deleteWithCascadeL119-151,timelineEvents.deleteL711-723,notes.deleteL835-840,tasks.deleteL511-534 ·adiuvAI/src/main/api/drizzle-executor.tshandleDeleteL329-340 - Severity: High (data integrity)
- Problem: No FK constraints, hand-rolled deletes, no
db.transaction()anywhere in src/main: project delete orphans timelineEvents/notes/projectFolderFiles/noteEdits; event delete leaves dangling dependency edges that poison the cycle-guard BFS (L756-772, falsely rejects new deps); AI-issued deletes (handleDelete) cascade nothing — orphan rows + attachment files on disk;tasks.deleteinterleaves file unlinks between row deletes (partial state on crash). - Fix: Pre-1.0 clean option (preferred per Roberto's preference): add
references(..., { onDelete: 'cascade' })to schema + enablePRAGMA foreign_keys = ONindb/index.ts, generate migration. Alternative: sharedcascadeDelete{Task,Project,Client,Note,TimelineEvent}helpers in a newsrc/main/db/cascade.ts, used by BOTH tRPC routers andhandleDelete, wrapped indb.transaction()(better-sqlite3 sync transactions), file unlinks after commit. - Risk: FK route requires verifying every insert ordering satisfies constraints, and migration against existing user DBs with already-orphaned rows (clean orphans first). Test matrix: delete project with events+deps+notes+pending edits; agent-delete task with attachments → no orphan rows, files removed. Human review required — this deletes data.
CORR-06 — WS drop wedges folder indexing in 'scanning'
- Files:
adiuvAI/src/main/api/backend-client.ts:1099-1100(listeners cleared,onDonenever called) ·adiuvAI/src/main/files/indexer.ts:106-117(onlyfinalizeresets status) · consumers refusing rescan:drizzle-executor.ts:486,files/daily-rescan.ts:22,router/projectFolders.ts:61 - Severity: High
- Problem: WS close clears
indexListenerswithout invoking callbacks →folderLastScanStatusstays'scanning'forever → all future scans refuse to run. Recovery currently requires manual DB surgery. - Fix: (1) In the WS close handler, before
indexListeners.clear(): iterate and call eachonDone('error'). (2) Startup recovery ininitDb()or app-ready:UPDATE projects SET folder_last_scan_status='error' WHERE folder_last_scan_status='scanning'. - Risk: None meaningful. Test: kill backend mid-index → status becomes 'error', rescan works.
CORR-07 — backend-client close handler clobbers new socket
- File:
adiuvAI/src/main/api/backend-client.ts:1079-1104 - Severity: Medium-High
- Problem:
ws.on('close')unconditionally nullspersistentWs, stops heartbeat, rejects+clears ALL listener maps. Logout→login: the old socket's deferred close event destroys the new connection's state and schedules a duplicate reconnect. - Fix: Capture
wsin closure; first line of the close and error handlers:if (this.persistentWs !== ws) return;. Same guard inside the heartbeat interval callback. - Risk: Low. Test logout+immediate login → exactly one socket, in-flight chat on new socket survives.
CORR-08 — Decay applied per invocation, not per period
- File:
api/app/core/memory_maintenance.py:280-309(proactive, runs hourly),:61-104(relations) - Severity: Medium
- Problem:
periods = days_elapsed // PERIOD; new_confidence = confidence * FACTOR**periodsrecomputed from immutablecreated_atand re-applied every tick — a row ≥8 days old loses 10% per hour (intended: per 7 days). - Fix: Compute confidence as a pure function of age each read, OR add
last_decayed_atcolumn (migration) and decay only(now - last_decayed_at) // PERIODperiods, updating the column. Pure-function approach avoids the migration — preferred. - Risk: Recompute approach changes stored semantics — confirm nothing else mutates
confidence(confirmation boosts do; thenlast_decayed_atis the correct route). ⚠️ REVIEW the choice.
CORR-09 — Check-then-insert races, missing unique constraints ⚠️ REVIEW
- Files:
api/app/core/memory_middleware.py:232-247(update_core),:421-450(upsert_relation);api/app/api/routes/auth.py:125-127(register IntegrityError→500) - Severity: Medium
- Problem: No
UNIQUE(user_id, key)on memory_core, none on memory_relations(user_id, subject_label, predicate, object_label). Concurrent writers (documented fire-and-forget language seeding + onboarding PUT) create duplicates;scalar_one_or_none()then raisesMultipleResultsFound. Register duplicate-email race returns 500 instead of 409. - Fix: Alembic migration adding both unique constraints (dedupe existing rows first: keep newest per key). Convert both writers to PostgreSQL
INSERT ... ON CONFLICT DO UPDATE(copy the savepoint pattern fromscouts/engine.py:123-135). Register: catchIntegrityError→ 409. - Risk: Migration fails if production data has duplicates — dedupe step mandatory. Test concurrent update_core calls.
CORR-10 — Fire-and-forget create_task: no refs, no concurrency cap
- Files:
api/app/api/routes/device_ws.py:120,130,171-215; alsoscouts.py:247-249,memory_middleware.py:201,note_agent.py:84,112 - Severity: Medium
- Problem: Bare
asyncio.create_task— loop holds weak refs (GC can collect mid-flight); exceptions surface only at GC; a frame-spamming client spawns unbounded concurrent agent runs (WS path has no rate limit). - Fix: Per-connection
tasks: set[asyncio.Task]; on spawn:t = create_task(...); tasks.add(t); t.add_done_callback(tasks.discard). In WSfinally: cancel all. Cap: if ≥3 in-flight stream-producing requests for the connection, reply immediately withstream_enderror frame "busy". - Risk: Cap value is judgment — confirm 3 is right for legitimate parallel use (home + contextual + brief). ⚠️ REVIEW the cap.
CORR-11 — tasks.update wipes briefing + chat history on every update
- File:
adiuvAI/src/main/router/index.ts:504-506; hash helper exists at:55-64; staleness check at:976-989 - Severity: Medium (silent user-data loss)
- Problem: Every update — including a kanban status drag — deletes
taskBriefings+taskBriefChats. - Fix: Load the task before update; compute
hashTaskForBriefing(prev)vs hash of merged next state; run the two deletes only when hashes differ. - Risk: Verify
hashTaskForBriefingcovers exactly the fields the briefing depends on. Test: status drag preserves brief chat; title edit invalidates it.
CORR-12 — Stream errors emitted as plain stream_end
- File:
adiuvAI/src/main/ai/orchestrator.ts:105,111,141,147,192,198; renderer persistence of empty messageContextualChatContext.tsx:187-204 - Severity: Medium
- Problem:
onErrorsends a normalstream_end(no error flag); catch blocks sendstream_endwithrequestId: ''(matches nothing). Mid-stream failure looks like success → blank assistant bubble persisted to SQLite. - Fix: Extend the stream-end frame with optional
error?: stringinsrc/sharedtypes (mirror backend's existing error-capableWsStreamEnd). orchestrator: pass real requestId + error message; delete therequestId: ''sends. Renderers: onerror, show error bubble, skipappendMessagepersistence when content empty. Backend twin fix: CORR-15. - Risk: Frame contract change across main↔renderer — do together with CORR-04.
CORR-13 — withRetry retries non-idempotent POSTs ⚠️ REVIEW
- File:
adiuvAI/src/main/api/backend-client.ts:700-723 - Severity: Medium
- Problem:
proxyPostretried up to 3× on timeout/5xx/429 — a timed-out-but-processed/scouts/triggeror scout create duplicates server work; immediate retry on 429 worsens it. - Fix: Add
{ retry?: boolean }option toproxyPostdefaulting false; enable only for verified-idempotent endpoints (reads proxied via POST, if any). ExcludeRateLimitErrorfrom retry entirely (or respect Retry-After). GET/DELETE-by-id keep retry. - Risk: Requires classifying every proxyPost call site idempotent-or-not — review the list.
CORR-14 — Sync Stripe calls block the event loop
- File:
api/app/billing/stripe_service.py:71-79, 197, 225-238(auto_paging_iter paginates network synchronously) - Severity: Medium
- Fix: Wrap each Stripe call in
await asyncio.to_thread(...); forauto_paging_iter, collect inside the thread:await asyncio.to_thread(lambda: list(itertools.islice(it, 100))). - Risk: None behavioral. Mechanical once pattern set.
CORR-15 — run_home_stream swallows exceptions; no error stream_end
- File:
api/app/api/routes/device_ws.py:295-307; correct pattern at:498-505(brief) - Severity: Medium
- Problem: On exception: logged, partial chunks stored as an episode, client never receives
stream_endwith error → renderer hangs (pairs with CORR-04). - Fix: In the except block: send
WsStreamEnd(request_id=..., error="Internal error")(generic per SEC-30); skipstore_episodewhen no response text was produced. - Risk: None. Test: raise inside agent → client gets error frame.
CORR-16 — delete_account: partial transaction + broad except ⚠️ REVIEW
- File:
api/app/api/routes/auth.py:759-795 - Severity: Medium
- Problem: Stripe cancel commits its own transaction, then memory deletes, then user delete + final commit; failure mid-way = cancelled subscription with live account.
except Exception: passat L786-787 hides genuine failures. (DB-levelondelete="CASCADE"makes the manual memory deletes redundant.) - Fix: Reorder: all DB deletes in one transaction first (drop the redundant manual memory-table deletes — FK cascade covers them; verify each memory table's FK has
ondelete="CASCADE"in migrations before relying on it), commit, THEN Stripe cancel; on Stripe failure log + enqueue retry rather than failing the deletion. Narrow the except to expected types. - Risk: Account deletion is irreversible — human review + test with seeded data.
CORR-17 — Index-session TOCTOU + stale _active entry
- Files:
adiuvAI/src/main/files/indexer.ts:67(startIndexSession) ·adiuvAI/src/main/router/projectFolders.ts:84-99· trigger sitesdrizzle-executor.ts:487-501,files/daily-rescan.ts:22 - Severity: Medium
- Problem: Three concurrent triggers can all pass the
!== 'scanning'check before any writes 'scanning'._active.setruns afterawait startIndexSessionreturns — a zero-delta session completes before the entry exists, leaving stale{status:'starting'}. - Fix: Module-level
const _inFlight = new Set<string>()in indexer.ts; first synchronous statement ofstartIndexSession:if (_inFlight.has(projectId)) throw new Error('Scan already in progress'); _inFlight.add(projectId); remove infinalize's finally. In projectFolders.ts, register the_activeentry before awaiting. - Risk: Low. Also add
.catch(console.error)to thevoid startIndexSession(...)fire-and-forget sites (daily-rescan.ts:25, drizzle-executor.ts:497).
CORR-18 — Scout trigger TOCTOU · Low
- File:
api/app/api/routes/scouts.py:225-249(+scout_runner.py:577) - Fix: Add
stable_agent_idto_running_agentssynchronously in the route beforedb.commit()/create_task; discard on spawn failure. Keep the in-task add idempotent.
CORR-19 — Swallowed-error triage · Low
- Files/fixes (mechanical batch):
adiuvAI/src/main/router/index.ts:1156-1164, 1276-1294, 1374-1382, 1429-1434— scout list/labels/catalog/runs return[]on any error → return{ data, error }envelope (or throw, per QUAL-11's chosen convention).adiuvAI/src/main/api/drizzle-executor.ts:566-568— includeerror: messagein thekind:'error'result.adiuvAI/src/main/auth/auth-manager.ts:594— wrapJSON.parse(text)in try/catch, return{}on failure.- Documented-intentional
.catch(() => {})seeding (router/index.ts:1577,1585) — leave as is.
CORR-20 — Naive/aware datetime kludge · Low
- File:
api/app/api/routes/auth.py:205— unconditional.replace(tzinfo=timezone.utc)would mislabel a non-UTC value. Fix:if rt.expires_at.tzinfo is None: rt_exp = rt.expires_at.replace(tzinfo=timezone.utc) else: rt_exp = rt.expires_at(pattern fromscout_runner.py:166-167).
CORR-21 — Renderer/main misc · Low
adiuvAI/src/renderer/index.tsx:22-30— moveLanguageSynccomponent definition to module scope (currently declared insideApp, remount trap).adiuvAI/src/main/ipc.ts:68—undefined as unknown as AbortSignal: make the field optional in the local type (see TYPE-05 family).
3. Performance
PERF-01 — bcrypt on the event loop
- File:
api/app/api/routes/auth.py:69-74, called at:134, :169, :656-659 - Severity: High
- Problem: 100-300 ms CPU per call freezes ALL WS streams/tool round-trips; login is currently rate-limit-exempt (SEC-02) → credential-stuffing stalls the whole server.
- Fix:
return await asyncio.to_thread(bcrypt.hashpw, password.encode(), bcrypt.gensalt())etc. — make_hash_password/_verify_passwordasync, update 3 call sites. - Risk: None. Verify tests still pass (auth suite exists).
PERF-02 — Home channel buffers all tokens (fake streaming) ⚠️ REVIEW
- File:
api/app/core/deep_agent.py:1219-1227(token withholding),:1075(ainvokenotastream) - Severity: High (UX latency)
- Problem:
run_home_streamaccumulates every token and yields ONE blob after the whole run (including tool loops). User sees nothing until the end. Root cause:_normalize_tagged_list_linesneeds the full text. - Fix: Switch the LLM call to
llm_with_tools.astream()accumulating tool-call deltas; make_normalize_tagged_list_linesline-buffered (process and flush complete lines as they arrive, hold only the current partial line). Measure time-to-first-frame before/after. - Risk: Tool-call delta accumulation across LangChain/LiteLLM is fiddly; the comment near L1080-1100 documents a past double-LLM-call bug that must not regress. Strong-model territory.
PERF-03 — Hourly mining duplicates + unbounded prompt injection
- File:
api/app/core/memory_maintenance.py:175-253;api/app/core/memory_middleware.py:703-718(_load_proactive, no LIMIT); cron atapi/app/main.py:46-77 - Severity: High (cost + correctness)
- Problem: Hourly re-mining of the same 30-day window, 3-5 new rows per tick, no dedup → ~100 near-duplicate rows/user/day, all decrypted into every chat prompt.
- Fix: (1)
_load_proactive: add.order_by(confidence.desc()).limit(20). (2) Mine only when episodes newer than the user's latestMemoryProactive.created_atexist. (3) Dedup new patterns against existing (reusedecide_action). (4) Consider daily cadence for the mining portion of the cron. - Risk: (1) is mechanical and urgent; (2)-(4) need the memory test suites re-run.
PERF-04 — No SQLite indexes (Electron)
- File:
adiuvAI/src/main/db/schema.ts(only ai_chat tables indexed, migration 0006) - Severity: Medium (High at scale — scans are synchronous, blocking all IPC)
- Fix: Add Drizzle
index()on:tasks.projectId,tasks.dueDate,taskComments.taskId,taskAttachments.taskId,notes.projectId,noteEdits.noteId,timelineEvents.projectId,timelineEventDependencies.fromEventId+.toEventId,scoutRunActions.runId;uniqueIndexonprojectFolderFiles(projectId, relativePath)(indexer does a per-file SELECT by that pair —files/indexer.ts:128-136, O(n²)).npx drizzle-kit generate. - Risk: Migration must apply cleanly on existing user DBs (bootstrapMigrationsLedger path) — test against a populated dev DB.
PERF-05 — Missing PG indexes; refresh_tokens never purged
- File:
api/app/models.py(+ alembic migration needed) - Severity: Medium
- Fix: One Alembic migration adding:
memory_core(user_id, key)(unique — shared with CORR-09),memory_episodic(user_id, created_at DESC),memory_relations(user_id, subject_label, predicate, object_label)(unique — CORR-09),scout_run_logs(user_id, started_at), HNSW index onmemory_associative.embedding(USING hnsw (embedding vector_cosine_ops)),refresh_tokens(expires_at). Add a daily cleanup to the existing cron:DELETE FROM refresh_tokens WHERE expires_at < now(). - Risk: HNSW build time on large tables; coordinate with CORR-09 dedupe.
PERF-06 — Per-token IPC + full markdown re-parse
- Files:
adiuvAI/src/main/ai/orchestrator.ts:103,139,180·adiuvAI/src/renderer/components/ai/ChatSurface.tsx:197-199,352-358·useAIChat.ts:185-188,ContextualChatContext.tsx:183-185,TaskBriefChat.tsx:98-101 - Severity: Medium
- Problem: token → IPC message → setState → full ReactMarkdown re-parse of the accumulated text + smooth scroll, per token. Quadratic on long answers.
- Fix: Coalesce in main: buffer chunks per requestId, flush on 40 ms timer (and on stream_end). Renderer: render the in-flight message as plain text (whitespace-pre-wrap), run ReactMarkdown only on
stream_end; throttle autoscroll with requestAnimationFrame. - Risk: Ensure stream_end flushes the tail buffer; visual check that markdown "pops in" acceptably at end.
PERF-07 — Serial tool calls per LLM step
- File:
api/app/core/deep_agent.py:953-986, 1103-1137;api/app/core/scout_runner.py:289-312 - Severity: Medium
- Fix:
results = await asyncio.gather(*(t.ainvoke(args) for ...)), then appendToolMessages preserving the original tool_call id order. Apply in all three loops (or once, after QUAL-03's shared loop). - Risk: Concurrent tool calls hit the Electron executor concurrently — drizzle-executor handlers are async over a sync driver, fine, but verify pending-call map handles parallel ids (it's keyed by id — fine). Order preservation matters for the LLM.
PERF-08 — Blocking Langfuse flush/get_prompt
- Files:
api/app/core/deep_agent.py:1009-1010,1165-1166·scout_runner.py:323-324·memory_extraction.py:319-322·langfuse_client.py:99 - Severity: Medium
- Fix: Delete the per-request
lf.flush()calls (SDK background-flushes; keep one flush in app shutdown lifespan). Wraplf.get_prompt(...)inasyncio.to_threadat the call sites in langfuse_client, or pre-warm the prompt cache at startup. - Risk: Traces may lag a few seconds in Langfuse UI — acceptable.
PERF-09 — get_current_user does 3 queries + full memory decrypt per request
- File:
api/app/api/middleware/auth.py:62-91 - Severity: Medium
- Fix: Split dependencies:
get_current_user(user+tier in ONE query via outerjoin on subscriptions, no memory) andget_current_user_with_memory(adds core-block decrypt). Switch only the routes that actually read.memory(chat/profile) to the latter; scout CRUD, billing, etc. use the light one. - Risk: Grep every
current_user.memoryaccess before switching routes. Test suite covers auth paths.
PERF-10 — Redundant memory-middleware queries; per-key commits
- Files:
api/app/core/memory_middleware.py(_get_fernet/_get_user_debugper method) ·api/app/api/routes/auth.py:560-575(update_memoryloop) - Severity: Medium
- Fix: Cache
(user, fernet, tier)on the middleware instance per request (it's constructed per call — add lazy_ctxloaded once).update_memory: add aupdate_core_many(dict)method doing one SELECT of existing keys, one bulk write, one commit. - Risk: Instance lifetime check: confirm MemoryMiddleware isn't a long-lived singleton (it takes
dbper call — if constructed per request, caching is safe).
PERF-11 — Health check before every chat
- File:
adiuvAI/src/main/ai/orchestrator.ts:76-87 - Fix: In
checkConnectivity, return early-success whenbackendClient.persistentWs?.readyState === OPEN(expose aisWsConnected()getter); keep the HTTP health check only as fallback when WS down. - Risk: None —
sendHomeRequestalready rejects with OfflineError when WS is down.
PERF-12 — Paid brief regen per mutation
- File:
adiuvAI/src/main/router/index.ts:447,502,532,668,706,721→orchestrator.ts:240-254(1.5 s debounce) - Severity: Medium (LLM cost)
- Fix: Replace eager regeneration with dirty-flag:
invalidateBriefCache()only marks the cache stale; regeneration happens lazily on nextdailyBriefread or the existing scheduler slot tick. DeletescheduleBriefRegeneration. - Risk: First home-page visit after edits pays the generation latency — show the stale brief while regenerating (cache already stores it).
PERF-13 — No code-splitting; heavy eager imports
- Files:
adiuvAI/vite.renderer.config.mts:13-16,vite.web.config.mts:22·ChatChartBlock.tsx:20(recharts) ·routes/notes.$noteId.tsx:27(Milkdown) - Fix: Add
autoCodeSplitting: trueto TanStackRouterVite in both configs.React.lazy+ Suspense forChatChartBlock(renders only on<chart>tag) andMilkdownEditor. - Risk: Test chart block + notes editor render after splitting (Suspense fallback flash).
PERF-14 — Default QueryClient → focus refetch storms (mechanical)
- File:
adiuvAI/src/renderer/index.tsx:15(contrastweb-main.tsx:24) - Fix:
new QueryClient({ defaultOptions: { queries: { staleTime: 30_000, refetchOnWindowFocus: false } } })— local writes already invalidate explicitly. - Risk: Multi-window staleness (single-window app — fine).
PERF-15 — Double folder walk; no WS backpressure
- Files:
adiuvAI/src/main/router/projectFolders.ts:65+files/indexer.ts:67,191-215 - Fix: Pass the pre-flight
ScanDeltaintostartIndexSession(skip re-walk). In the batch loop, checkws.bufferedAmountand await drain (or gate ononProgressacks) before sending the next batch. - Risk: Backpressure logic needs a stall timeout; test with a large folder.
PERF-16 — DB sessions across LLM calls
- Files:
api/app/scouts/engine.py:48-70·api/app/api/routes/device_ws.py:736-829; pool configapi/app/db.py:24-28 - Fix: Restructure to short sessions: load needed rows → close → LLM work → new session for writes. Interim: raise
pool_size/max_overflowand set explicitpool_timeout. - Risk: Detached-instance errors after session close — copy needed attrs to plain objects first.
PERF-17 — Per-file metadata WS round-trips
- File:
api/app/core/scout_runner.py:386-405 - Fix: Extend the Electron
list_directoryexecutor result to includemtimeMs/sizeper entry (samefs.statit already does for dirents —drizzle-executor.ts:375-398); backend reads mtimes from the listing, drops per-fileget_file_metadatacalls. - Risk: Two-repo protocol change; version-skew tolerance (backend falls back to old path if field absent).
PERF-18 — Per-call AsyncOpenAI clients (mechanical)
- Files:
api/app/core/embeddings.py:23·api/app/core/llm.py:154 - Fix: Module-level
_client: AsyncOpenAI | Nonelazy singleton; reuse across calls.
PERF-19 — tasks.list client-side pagination · Low
- Files:
adiuvAI/src/main/router/index.ts:314-317·TaskListView.tsx:80,101 - Fix: Pass
limit/offsetfrom TaskPager state; addstatusInfilter server-side for theactivepseudo-filter (L94-95). Defer until data sizes warrant.
PERF-20 — Limiter dict growth; tier from JWT · Low
- File:
api/app/api/middleware/rate_limit.py:81,97-98,107-128 - Fix: Delete empty lists after trim; superseded entirely if SEC-17's Redis lands. Tier-from-JWT staleness: document or read from DB (cheap once PERF-09's joined query exists).
PERF-21 — Unbounded reads in drizzle-executor · Low
- File:
adiuvAI/src/main/api/drizzle-executor.ts:623-630(page details),:535-548(pdf/docx/image base64 uncapped) - Fix: Apply default limit 50 (as
handleSelectdoes) to the_allpage-detail queries; enforce the 500 KB cap on binary reads too (reject larger with explicit error).
PERF-22 — Context re-render hotspots · Low
- Files:
adiuvAI/src/renderer/context/HeaderContext.tsx:30(unmemoized value) ·ContextualChatContext.tsx:253-270+hooks/useContextualScope.ts:5 - Problem: Every route re-renders per streamed sidebar token (scope consumers subscribe to the same context as
streamingContent). - Fix:
useMemothe HeaderContext value. Split ContextualChatContext into stable-actions context (setScope,open,toggle) and volatile stream context (messages,streamingContent,isStreaming);useContextualScopeconsumes only the stable one.
4. Dead Code
DEAD-01 — Five dead Python deps · High (mechanical)
- File:
api/requirements.txtlines 12, 25, 26, 27, 31 - Problem:
boto3,moto[s3],pinecone,qdrant-client,google-auth-oauthlib— zero imports/refs anywhere (grep-verified; pgvector is the live vector path;oauth_providers.py:71explicitly states it does NOT use google-auth-oauthlib). - Fix: Delete those 5 lines. Run
pytestafter. Keeppython-dotenv(pydantic-settingsenv_fileneeds it) andgoogle-auth-httplib2(transitive need of googleapiclient).
DEAD-02 — Orphaned scout_registry.py · High (mechanical)
- File:
api/app/core/scout_registry.py—BaseAgent, zero importers, zero subclasses, references deleted vector-store era. Fix: delete file; run pytest.
DEAD-03 — slowapi dead · Medium (mechanical)
- Files:
api/app/api/middleware/rate_limit.py:26-28,52,66-67·middleware/__init__.py:10,17·requirements.txt:13 - Problem:
Limiterexported "for optional route-level decoration" — zero@limiterdecorated routes exist. - Fix: Remove the Limiter creation/exports and the requirement. Sequencing: do AFTER SEC-02 (which may or may not reuse slowapi — recommended fix reuses the custom window, so slowapi still goes).
DEAD-04 — chat.py HTTP routes production-unused ⚠️ REVIEW (owner decision)
- Files:
api/app/api/routes/chat.py:40,63,105; stale commentsadiuvAI/src/main/api/backend-client.ts:9,14 - Problem: Electron client hits none of
POST /chat,/chat/brief,/chat/embed(all traffic onWS /api/v1/ws/device). Routes are test-covered but production-dead — unless an external consumer exists. - Fix: Roberto confirms no external consumers → delete routes + their tests (
test_brief_agent.py/test_middleware.pyportions that exercise them — keep middleware tests by retargeting another POST route). Either way, fix the stale doc comments in backend-client.ts (mentions nonexistentWS /api/v1/chat/stream) andadiuvAI/src/shared/api-types.ts:338,390,425(references nonexistent/agents/*HTTP routes). Note CLAUDE.md path drift: actual WS route is/api/v1/ws/device, docs say/api/v1/device.
DEAD-05 — Four unused npm deps · Medium (mechanical)
- File:
adiuvAI/package.json:56,76,77,78 - Fix:
npm uninstall next-themes mammoth pdf-parse @hello-pangea/dnd(knip + grep verified zero imports; mammoth/pdf-parse are LanceDB-era leftovers).
DEAD-06 — Dead Electron modules · Medium
- Files (knip-verified, delete):
adiuvAI/src/shared/batch-types.ts(~5 KB),src/renderer/hooks/useChatStream.ts(has the CORR-04 bug — delete, don't fix),src/renderer/hooks/useTaskBriefCache.ts,src/renderer/components/agents/ScoutRunLog.tsx,src/renderer/components/ai/blocks/index.tsx(dead barrel). - ⚠️ REVIEW one:
blocks/ChatTableBlock.tsxis dead only because the barrel is — checkapi/app/core/output_formatter.pyfirst: if the backend emits table segments, wire ChatTableBlock into ChatSurface's segment switch instead of deleting (missing feature, not dead code).
DEAD-07 — backup-key.ts unwired ⚠️ REVIEW (owner decision)
- File:
adiuvAI/src/main/auth/backup-key.ts— zero importers, but CLAUDE.md documents it as deliberate architecture. Fix: Roberto decides: wire into the backup feature roadmap or delete (recoverable from git). Interacts with SEC-21 (its storage fallback) and SEC-28 (could encrypt attachments).
DEAD-08 — knip.json missing web entries · Medium (mechanical)
- File:
adiuvAI/knip.json - Problem:
web-main.tsx+lib/httpLink.tsfalse-flagged (live viaweb.html:11). - Fix: Add
"web.html"and"src/renderer/web-main.tsx"to theentryarray; optionally add ignore rule for TanStackRouteexports. Do this FIRST so subsequent knip runs are trustworthy.
DEAD-09 — langchain meta-package; websockets pin · Medium (mechanical)
- File:
api/requirements.txt:4,20 - Fix: Replace
langchain>=0.3.0withlangchain-core>=0.3.0(onlylangchain_core/langchain_openai/langchain_litellmare imported). Drop explicitwebsocketspin (uvicorn[standard] bundles it). Run pytest.
DEAD-10 — Ruff F401 batch · Low (mechanical)
- Fix:
cd api && ruff check . --select F401 --fix(13 findings: quota.py:7, deep_agent.py:1247, 11 test files). Keep the ERA001-flagged comment blocks (scouts.py:438-457, email_html.py:22 — documentation, add# noqa: ERA001if noisy).
DEAD-11 — Dead TS exports · Low (mechanical)
- Fix: Remove
export(or delete bodies):getDbPath/getRawSqlite/closeDb(src/main/db/index.ts:118-137),generateAndCacheBrief(orchestrator.ts:257— superseded if PERF-12 lands; coordinate),attachmentsRoot(attachments/storage.ts:17),parseMutationsToEntityTags(useAIChat.ts:76),formatTime(lib/date.ts:72),parseDateRange(lib/parseDate.ts:151). Keep error classes exported (instanceof checks); keepwebPlatformuntil DEAD-08 confirms.
DEAD-12 — Dev deps in runtime requirements · Low (mechanical)
- File:
api/requirements.txt:22-25,41 - Fix: Move
pytest,pytest-asyncio,aiosqlite,ruffto a newrequirements-dev.txt; update Dockerfile to install only runtime file; CI installs both.
5. Dependencies
DEPS-01 — ws 8.19.0 vulnerable · High (mechanical)
- File:
adiuvAI/package.json— GHSA-58qx-3vcg-4xpx (uninitialized memory disclosure, affects ≤8.20.0). Only runtime-reachable vuln of the 58 npm audit hits. Fix:npm audit fix(→ ^8.21.0, semver-compatible).
DEPS-02 — python-jose floor permits CVE versions · High (mechanical)
- File:
api/requirements.txt:10— floor 3.3.0 permits CVE-2024-33663 (algorithm confusion) / CVE-2024-33664 (JWE DoS), fixed in 3.4.0. App pins HS256 with explicitalgorithms=[...]everywhere (mitigates the confusion path), but raise anyway. Fix:python-jose[cryptography]>=3.4.0. ⚠️ REVIEW (later): consider migrating to PyJWT (python-jose barely maintained) — separate ticket.
DEPS-03 — cryptography floor · Medium (mechanical)
- File:
api/requirements.txt:34— floor 42.0.0 permits CVE-2024-26130 versions. Fix:cryptography>=43.0.1.
DEPS-04 — No Python lock file · Medium
- Problem: All
>=floors; installed versions unauditable. Fix: Adoptuv lockorpip-compile; commit the lock; runpip-auditagainst it in CI. ⚠️ REVIEW tool choice.
DEPS-05 — eslint 8 + @typescript-eslint 5 EOL ⚠️ REVIEW
- File:
adiuvAI/package.json:38-39— ts-eslint v5 predates TS 5.x support; lint coverage degraded. Fix: Dedicated PR: eslint 9/10 flat-config migration + typescript-eslint 8. Medium effort (config rewrite).
DEPS-06 — Electron 40 → 42 ⚠️ REVIEW
- Fix: Dedicated PR bumping electron; rebuild/retest better-sqlite3 (forge cross-compilation hooks download platform binaries — verify versions), full smoke of packaged app.
DEPS-07 — Transitive toolchain advisories · Low (no action)
tmp/tarvia @electron-forge 7.x,esbuildvia drizzle-kit — build-machine-only, no fix available. Do NOT runnpm audit fix --force(downgrades forge/drizzle-kit). Monitor forge releases.
DEPS-08 — @types/ws placement · Low (mechanical)
- File:
adiuvAI/package.json:65— move to devDependencies.
6. Code Quality
All QUAL items execute in Phase 6, after behavior fixes, because they move code and invalidate line references. Items marked ⚠️ involve behavior-preservation judgment.
QUAL-01 — Split router/index.ts (1967 LOC) · High
- Fix: Split along existing boundaries into
src/main/router/{trpc.ts,clients.ts,projects.ts,tasks.ts,timeline.ts,notes.ts,ai.ts,scout.ts,auth.ts,memory.ts,settings.ts,attachments.ts}; boundaries: clients 71-152, projects 154-228, tasks 230-560, timelineEvents 561-725, deps 726-787, notes 788-842, taskComments 843-877, settings 878-922, ai 923-1090, scout* 1091-1516, auth 1517-1729, memory 1730-1784, noteEdits 1785-1867, taskAttachments 1868-1946.trpc.tsholds thet/router/publicProcedurefactory;index.tsbecomes theappRoutermerge +AppRouterexport. Move brief helpers (L25-65) tosrc/main/ai/brief-cache.ts(with PERF-12). - Risk: Low — routers are independent consts. Verify
AppRoutertype output unchanged (renderer compiles).
QUAL-02 — Merge _run_single_agent twins ⚠️ REVIEW · High
- File:
api/app/core/deep_agent.py:873-1010vs:1013-1166(~90% identical) - Fix: Keep only the streaming generator; non-stream variant = join the tokens. Preserve the documented behavior at L1080-1100 (stream version intentionally avoids a second LLM call — past bug) as the single canonical path.
- Risk: Every chat path runs through this. Run full agent test suites; manual chat smoke.
QUAL-03 — One LLM tool loop, four implementations ⚠️ REVIEW · High
- Files:
deep_agent.py:873,1013·scout_runner.py:222-324·scout_setup.py:237-349 - Fix: New
api/app/core/tool_loop.py:async def run_tool_loop(llm, tools, messages, *, max_steps, lf_name, on_token=None)yielding events. Four call sites become configuration. Usecontextlib.ExitStackfor Langfuse observations (kills the manual__enter__/__exit__). Parametrize observation names per site. Do AFTER QUAL-02 (reduces to three sites). - Risk: Subtle per-site differences (Langfuse naming, error handling) — diff behavior carefully; PERF-07's gather lands here once.
QUAL-04 — tRPC CRUD factory ⚠️ REVIEW · High
- File:
adiuvAI/src/main/router/index.ts(8 tables repeat list/get/create/update/delete +if (input.x !== undefined)set-builders) - Fix:
router/crud-factory.ts:makeCrudRouter(table, { createSchema, updateSchema, defaultOrder }); pluspickDefined(input, keys)helper for update-set building. Prototype onclientsfirst; domain procedures spread in. - Risk: Generic factories can degrade tRPC type inference (
AppRoutermust stay exact for the renderer). If inference breaks, keep per-table routers and extract onlypickDefined+ create/update helpers.
QUAL-05 — Split deep_agent.py (1329 LOC) · High
- Fix: (a)
app/core/prompt_context.py←_*_injectionfunctions,_request_context_block,format_folder_manifest,_prepare_context(L43-519); (b)app/core/agent_tools.py←_memory_tools(684-826),_read_only_memory_tools,_brief_research_tools,_all_tools_for_user,_contextual_tools,get_page_details(521-871); (c)deep_agent.pykeeps runner +run_*entries. Pure moves; do after QUAL-02/03.
QUAL-06 — backend-client.ts split + dispatch extract ⚠️ REVIEW · Medium
- Fix: (a)
backend-errors.ts← 5 error classes (L134-203); (b) extractprivate dispatchFrame(frame)+ per-frame handlers from the 230-lineopenDeviceWebSocket(L876-1106); (c)sendStreamFrame(frameType, payload, callbacks)helper deduping the sixsend*methods (L276-660); (d) moverecordRunAction(L55, SQLite write) tosrc/main/scouts/scout-run-log.ts. Full split into connection/requests modules optional second step. - Risk: Shared mutable state (
streamListeners,persistentWs) — extract as methods first, modules later. Do after CORR-07's guards land.
QUAL-07 — scout_runner.py split + twin dedupe ⚠️ REVIEW · Medium
- Fix:
app/core/scouts/{local_runner.py,cloud_runner.py,runner_common.py}; extract_guard_device_online()and_process_items(items, process_one) -> RunStatsshared by the 210-line twins (run_local_agent556-766 /run_cloud_agent774-983). - Risk: Per-item error semantics differ slightly between local/cloud — write characterization tests first (suites exist).
QUAL-08 — device_ws.py handlers → package · Medium
- Fix: Keep
device_ws.pyas protocol layer (endpoint,_message_loop,_heartbeat_loop,_mark_runs_disconnected); move the 9_handle_*toapp/api/ws_handlers/{chat,brief,journey,indexing}.py. Handlers already take(websocket, user_id, payload). Do with QUAL-12.
QUAL-09 — ProjectSidebar.tsx (1292 LOC, 25+ useState) · Medium
- Fix: Extract
ProjectEditDialog.tsx,ClientManageDialogs.tsx,NewProjectDialog.tsx, and a sharedClientSelect(the client+sub-client+create-inline combo duplicated between edit and new-project dialogs with parallel state pairs). Pure UI extraction.
QUAL-10 — Ownership-check dependency · Medium (mechanical)
- File:
api/app/api/routes/scouts.py:344,372,391,407,702(pattern ×6) - Fix:
async def get_owned_cloud_scout(scout_id: UUID, current_user=Depends(get_current_user), db=Depends(get_session)) -> CloudScoutConfigraising 404; use as route param. Same pattern available for other resource routes.
QUAL-11 — Standardize tRPC error contract ⚠️ REVIEW · Medium
- File:
adiuvAI/src/main/router/index.ts— three styles:{error}(L109,113),{success:true},{data,error}envelopes (1157-1232), zeroTRPCError. - Fix: Standardize on throwing
TRPCError(ipcLink propagates,ipcLink.ts:76); migrate procedure-by-procedure WITH their renderer consumers (eachuseQuery/useMutationerror handling). Coordinates with CORR-04/CORR-19. Do during QUAL-01 split. - Risk: Every consumer touched — easy to miss one. Grep all
\.erroraccesses on tRPC results.
QUAL-12 — Typed WS frames server-side · Medium
- File:
api/app/api/routes/device_ws.py:424,620,704,767-820(rawjson.dumpsdicts) vs pydantic frames elsewhere; 7×# type: ignore[union-attr](L293-294,382-383,491,567-568) - Fix: Define pydantic models for index/journey reply frames in
app/schemas; use a discriminated union (Field(discriminator="type")) for inbound frames,model_validateat dispatch, error frame on ValidationError. Kills the type:ignores and the hand-rolled snake/camel fallbacks (L673-676,746-750).
QUAL-13 — Casing contract at WS boundary · Medium
- Evidence:
api/app/agents/note_agent.py:25readsrow.get("aiSummary") or row.get("ai_summary")— convention untrusted. - Fix: Document the contract ("frames snake_case, tool-result rows camelCase") in
api/app/core/ws_context.pydocstring +adiuvAI/src/shared/casing.ts; delete the dual-read hedges; add a TypedDict for rows (TYPE-07).
QUAL-14 — OAuth blocks → own files; shared TTLStateStore · Medium
- Fix:
app/api/routes/oauth.py← auth.py L45-67+299-505 (incl. extractinglink_or_create_oauth_user()from the 124-lineoauth_callback);app/api/routes/scout_gmail_oauth.py← scouts.py L456-807. Oneapp/auth/state_store.pyTTLStateStorereplacing both_pending_statesdicts — the seam where SEC-18's Redis backend plugs in. Sequence: build TTLStateStore during SEC-18, move files in Phase 6.
QUAL-15 — Agent helpers dedupe · Low (mechanical)
- Fix:
api/app/agents/_common.pywithis_uuid()(copies at task_agent.py:14-20, note_agent.py:15-22) andformat_rows(rows, line_fn, empty_msg).
7. Type Safety
TYPE-01 — Broken relative imports, types silently any · High (mechanical)
- Files:
adiuvAI/src/renderer/components/ai/blocks/ChatEntityBlock.tsx:13,ChatChartBlock.tsx:27,ChatTimelineBlock.tsx:5 - Problem:
'../../../../../shared/api-types'(5 levels = repo root) — one../too many.import typeso esbuild strips it without resolving; tsc confirms TS2307. Block data types areanythroughout. - Fix: Remove one
../in each of the three imports (→'../../../../shared/api-types').
TYPE-02 — No Python type checker · High ⚠️ REVIEW (config choices)
- Fix: Add
api/pyproject.tomlwith[tool.mypy]: startcheck_untyped_defs = true,warn_return_any = true; per-module strict forapp/core,app/api. Wire into CI besideruff check. First run will surface real bugs — budget a cleanup pass.
TYPE-03 — tsc doesn't pass; no CI typecheck · Medium
- Evidence: pre-existing errors at
backend-client.ts:963,drizzle-executor.ts:367,OnboardingFlow.tsx:157/165/291(+ TYPE-01 cascade). - Fix: After TYPE-01, fix the remaining tsc errors (inspect each — may be real bugs), then add
"typecheck": "tsc --noEmit"script + CI step.
TYPE-04 — RequestContext model for deep_agent · Medium
- File:
api/app/core/deep_agent.py(19×dict[str, Any];contextthreaded through L43-592+) - Fix:
RequestContextTypedDict/pydantic model inapp/schemas/(trace_id, session_id, format_prefs, proactive hints, relational memory, …);_trace_id_from_context/_session_id_from_context(L574-590) become attribute access and get deleted. Do with QUAL-05.
TYPE-05 — drizzle-executor cast cluster · Medium (mechanical)
- File:
adiuvAI/src/main/api/drizzle-executor.ts:73,159,262,317,335(table-as-Record ×5),:230,249(Boolean as unknown as (x) => x is SQL— dead weight, conditions alreadySQL[]) - Fix: One helper using drizzle's
getTableColumns():getColumn(table, name): SQLiteColumn | undefined; replace the record casts. Delete the Boolean predicate casts (and(...conditions)suffices).
TYPE-06 — window.electronAI escapes · Low (mechanical)
- Files:
ContextualChatContext.tsx:148-150,CloudScoutConfigPanel.tsx:39,CloudScoutCreationFlow.tsx:47,httpLink.ts:17 - Fix:
src/renderer/types/electron-api.d.tswithdeclare global { interface Window { electronAI?: ElectronAI; electronTRPC?: …; electronDialog?: … } }; add"types": ["vite/client"]to tsconfig (fixes(import.meta as any).env).
TYPE-07 — Boundary typing misc · Low
ToolResultTypedDict inapi/app/core/ws_context.py(rows,row,error) — types the most-trafficked boundary; pairs with QUAL-13.- 11 missing return annotations:
api/app/api/routes/scouts.pycloud CRUD handlers (302, 313, 337, 366, 758),device_ws.get_session_buffer:321— add once TYPE-02's checker exists. ProviderProtocol inapp/integrations/__init__.pyforfetch_messages/fetch_emails(kills 2 type:ignores in scout_runner.py:851+).adiuvAI/src/main/ipc.ts:55(response: any)→ type the tRPC response envelope;SerializedTRPCResponseinsrc/shared/closes the ipcLink seam (ipcLink.ts:66,76).
Notes for the Executor
- Line numbers reference
main@315c5d0. Re-verify each quoted snippet exists before editing; if it moved, find it by content, not line. - Never execute ⚠️ REVIEW items unattended. Security fixes especially: a sanitizer with a bypass or a broken auth check is worse than the original bug.
- After each Phase, run:
cd api && ruff check . && pytest·cd adiuvAI && npm run lint && npx tsc --noEmit && npm run knip(tsc becomes meaningful after TYPE-01/TYPE-03). There is no Electron test suite — manual smoke for renderer-affecting changes. - Two-repo changes (SEC-13, PERF-17, CORR-12, QUAL-13) must land backward-compatibly: backend accepts both old and new shapes for one release.
- Run
graphify update .after each modifying session (project rule). - Items needing owner decisions before any execution: SEC-16, SEC-22, SEC-28, DEAD-04, DEAD-07, CORR-08 (approach choice), DEPS-04 (tool choice).