Files
workspace/docs/REFACTOR_PLAN.md
2026-06-12 17:16:22 +02:00

863 lines
91 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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``handleListDirectory` L375-398, `handleReadFileContent` L400-436, `handleGetFileMetadata` L438-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_content` returns 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-33` passes 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.folderPath` values (query the projects table) plus nothing else. Use `const 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 existing `startsWith` containment in `handleReadProjectFolderFile` L527 to use the same `path.relative` check (defeats `C:\proj\foo-evil` vs `C:\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.ini` rejected.
### 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/login` and `/api/v1/auth/register` from `_EXEMPT_PATHS`. In `dispatch()`, when no token is present and `request.url.path.startswith("/api/v1/auth/")`, apply an IP-keyed sliding window (reuse the existing `_window` mechanism 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 at `api/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_KEY` pattern in `api/app/integrations/__init__.py:90-102`). Store `Fernet(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 on `LANGFUSE_BASE_URL`.
- **Fix:** Add `LANGFUSE_CAPTURE_CONTENT: bool = False` to settings. Wrap every `input=`/`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``handleInsert` L268-290, `handleUpdate` L292-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: `...data` is spread **after** `id`, so a backend-supplied `id` overrides 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 strip `id` and `createdAt` from incoming `data`/`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``createWindow` L85-117 (handlers absent repo-wide)
- **Severity:** High · OWASP A05:2021
- **Problem:** No `setWindowOpenHandler`, no `will-navigate` guard. Any navigation to remote content lands in the privileged renderer (which has `window.electronTRPC`/`electronAI`).
- **Fix:** In `createWindow` add:
```ts
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-src` unrestricted (compromised renderer exfiltrates anywhere).
- **Fix:** In production only (`!IS_DEV`), register `session.defaultSession.webRequest.onHeadersReceived` injecting: `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_ip` trusts `cf-connecting-ip` then `x-forwarded-for` unconditionally. Random header per request = fresh 5/min bucket → unlimited signup spam, Brevo quota burn, poisoned stored `ip_address`.
- **Fix:** Add `TRUSTED_PROXY_IPS: str = ""` setting. Trust `cf-connecting-ip` only when `request.client.host` is in that list; otherwise use `request.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.example` comment.
- **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_password` L73-74)
- **Severity:** Medium · OWASP A07:2021
- **Problem:** OAuth-only users have `password_hash=None`; `hashed.encode()` → uncaught `AttributeError` → 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: str` unconstrained (1-char/empty accepted); `_ChangePasswordRequest` already requires `min_length=8`. `email` is plain `str`, not `EmailStr`.
- **Fix:** Line 102: `password: str = Field(min_length=8)`; change `email: str` to `email: 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 matching `RefreshToken` row (SHA-256 the input, filter by hash + `user_id`). Optional `all: bool` to delete all the user's tokens. Wire Electron `auth-manager.ts` logout 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 `ws` package which supports handshake headers: pass `{ headers: { Authorization: \`Bearer ${token}\` } }` as the second arg to `new WebSocket(url, opts)`. Backend: read `websocket.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"` and `postgres:postgres` defaults; nothing refuses to start in prod with defaults → forgeable JWTs.
- **Fix:** Add a pydantic `model_validator` on `Settings`: if `ENV == "prod"` and (`JWT_SECRET == "change-me-in-production"` or `len(JWT_SECRET) < 32`), raise `ValueError` at import. Same guard hook used by SEC-19 (`GMAIL_PUBSUB_AUDIENCE` empty → 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 behind `logger.debug` + a `LOG_CONTENT=false` setting.
- **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 at `api/app/api/routes/memory.py:34-45`
- **Severity:** Medium · OWASP A02:2021 / GDPR
- **Problem:** `subject_label`/`object_label` hold names of user's contacts/employers (`works_at`, `reports_to`) in plaintext — third-party personal data. Only `notes_encrypted` protected.
- **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_relation` matching — 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` + `EXPIRE` or sorted-set window) keyed `rl:{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) with `set(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 at `api/app/config/settings.py:69`
- **Severity:** Medium · OWASP A05:2021/A07:2021
- **Problem:** Empty `GMAIL_PUBSUB_AUDIENCE` → `_verify_pubsub_jwt` returns True for ANY unauthenticated POST; attacker-supplied `emailAddress` triggers 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 checking `event.senderFrame`; all router procedures are `publicProcedure`. 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/handle` callback: validate `event.senderFrame.url` starts with the dev-server URL (dev) or the app's `file://` index (prod); silently drop otherwise. Extract a `isTrustedSender(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. `readFromStore` also 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 an `enc:` 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.example` passes.
- **Fix:** `origin in allowed` exact match; for Referer, compare `urllib.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_at` column (Alembic migration). In the existing-entry branch, skip the send if `last_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 no `integrity`/`crossorigin`; `@latest` ships 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 under `website/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_cancel` look up by `sessionId` without verifying `session["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 WS `finally` block, drop all `_index_sessions` entries whose `user_id == user_id` and `ws 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-side `logger.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:showOpenDialog` handler in a per-session `Set`; `attachments.create` rejects `sourcePath` not 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/callback` code+state forwarded to renderer with no pending-flow check (login OAuth path does this correctly in `auth-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; `handleDeepLink` drops 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 entry `id` only. Mechanical.
### SEC-34 — Website missing CSP/security headers · Low ⚠️ REVIEW
- **Fix:** At the reverse proxy serving `website/`: add `Content-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 `textContent` for all keys except an explicit whitelist of the 4 `data-i18n-html` keys that genuinely contain markup; never derive `t[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: false` are set (good), fuses are strong, but `sandbox` not enabled. Preload only uses `contextBridge`/`ipcRenderer`, so sandbox should be compatible.
- **Fix:** Add `sandbox: true` to 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 pattern `api/app/core/scout_runner.py:190-198`
- **Severity:** Critical
- **Problem:** (1) `await future` with no `asyncio.wait_for` — if Electron never replies (renderer crash, swallowed executor exception) the agent run hangs forever. (2) Frame is sent **before** `create_pending_call` registers the future; a fast `tool_result` arriving first no-ops on unknown id → permanent hang.
- **Fix:** Reorder: `future = device_manager.create_pending_call(user_id, payload["id"])` THEN `await websocket.send_text(...)`. Wrap: `return await asyncio.wait_for(future, timeout=30)` with `except asyncio.TimeoutError:` → pop the entry from `device_manager` `pending_calls` and 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:145` leaks it).
- **Risk:** 30 s must exceed slowest legitimate executor op (large folder file reads) — confirm against `read_project_folder_file` worst 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's `finally` calls `unregister(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 when `self._connections.get(user_id)` and `self._connections[user_id].ws is ws`. Update both call sites. (2) End the heartbeat promptly: replace `asyncio.gather(loop, heartbeat)` with `done, 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_id` never 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 `MemoryEpisodic` by `row.episode_id` (+ `user_id`), decrypt `summary` with the user's Fernet (reuse `memory_middleware._get_fernet` or 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.error` on mutation success → `isStreaming` stays true forever, `send()` no-ops, stream listeners leak (each leaked listener receives every future `ai:stream` frame).
- **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`); then `onError` is the single error path — update `useAIChat` accordingly. Each renderer call site: in the error path, call the active unsubscribe, push an error bubble, reset `isStreaming`/`isResearching`. Add `useEffect` unmount cleanup for `TaskBriefChat`'s send listener (L144). Delete dead `hooks/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.delete` L211-220, `clients.deleteWithCascade` L119-151, `timelineEvents.delete` L711-723, `notes.delete` L835-840, `tasks.delete` L511-534 · `adiuvAI/src/main/api/drizzle-executor.ts` `handleDelete` L329-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.delete` interleaves 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 + enable `PRAGMA foreign_keys = ON` in `db/index.ts`, generate migration. Alternative: shared `cascadeDelete{Task,Project,Client,Note,TimelineEvent}` helpers in a new `src/main/db/cascade.ts`, used by BOTH tRPC routers and `handleDelete`, wrapped in `db.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, `onDone` never called) · `adiuvAI/src/main/files/indexer.ts:106-117` (only `finalize` resets status) · consumers refusing rescan: `drizzle-executor.ts:486`, `files/daily-rescan.ts:22`, `router/projectFolders.ts:61`
- **Severity:** High
- **Problem:** WS close clears `indexListeners` without invoking callbacks → `folderLastScanStatus` stays `'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 each `onDone('error')`. (2) Startup recovery in `initDb()` 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 nulls `persistentWs`, 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 `ws` in 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**periods` recomputed from immutable `created_at` and 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_at` column (migration) and decay only `(now - last_decayed_at) // PERIOD` periods, updating the column. Pure-function approach avoids the migration — preferred.
- **Risk:** Recompute approach changes stored semantics — confirm nothing else mutates `confidence` (confirmation boosts do; then `last_decayed_at` is 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 raises `MultipleResultsFound`. 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 from `scouts/engine.py:123-135`). Register: catch `IntegrityError` → 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`; also `scouts.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 WS `finally`: cancel all. Cap: if ≥3 in-flight stream-producing requests for the connection, reply immediately with `stream_end` error 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 `hashTaskForBriefing` covers 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 message `ContextualChatContext.tsx:187-204`
- **Severity:** Medium
- **Problem:** `onError` sends a normal `stream_end` (no error flag); catch blocks send `stream_end` with `requestId: ''` (matches nothing). Mid-stream failure looks like success → blank assistant bubble **persisted to SQLite**.
- **Fix:** Extend the stream-end frame with optional `error?: string` in `src/shared` types (mirror backend's existing error-capable `WsStreamEnd`). orchestrator: pass real requestId + error message; delete the `requestId: ''` sends. Renderers: on `error`, show error bubble, skip `appendMessage` persistence 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:** `proxyPost` retried up to 3× on timeout/5xx/429 — a timed-out-but-processed `/scouts/trigger` or scout create duplicates server work; immediate retry on 429 worsens it.
- **Fix:** Add `{ retry?: boolean }` option to `proxyPost` defaulting false; enable only for verified-idempotent endpoints (reads proxied via POST, if any). Exclude `RateLimitError` from 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(...)`; for `auto_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_end` with error → renderer hangs (pairs with CORR-04).
- **Fix:** In the except block: send `WsStreamEnd(request_id=..., error="Internal error")` (generic per SEC-30); skip `store_episode` when 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: pass` at L786-787 hides genuine failures. (DB-level `ondelete="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 sites `drizzle-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.set` runs after `await startIndexSession` returns — 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 of `startIndexSession`: `if (_inFlight.has(projectId)) throw new Error('Scan already in progress'); _inFlight.add(projectId)`; remove in `finalize`'s finally. In projectFolders.ts, register the `_active` entry before awaiting.
- **Risk:** Low. Also add `.catch(console.error)` to the `void 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_id` to `_running_agents` synchronously in the route before `db.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` — include `error: message` in the `kind:'error'` result.
- `adiuvAI/src/main/auth/auth-manager.ts:594` — wrap `JSON.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 from `scout_runner.py:166-167`).
### CORR-21 — Renderer/main misc · Low
- `adiuvAI/src/renderer/index.tsx:22-30` — move `LanguageSync` component definition to module scope (currently declared inside `App`, 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_password` async, 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` (`ainvoke` not `astream`)
- **Severity:** High (UX latency)
- **Problem:** `run_home_stream` accumulates every token and yields ONE blob after the whole run (including tool loops). User sees nothing until the end. Root cause: `_normalize_tagged_list_lines` needs the full text.
- **Fix:** Switch the LLM call to `llm_with_tools.astream()` accumulating tool-call deltas; make `_normalize_tagged_list_lines` line-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 at `api/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 latest `MemoryProactive.created_at` exist. (3) Dedup new patterns against existing (reuse `decide_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`; `uniqueIndex` on `projectFolderFiles(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 on `memory_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 append `ToolMessage`s 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). Wrap `lf.get_prompt(...)` in `asyncio.to_thread` at 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) and `get_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.memory` access 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_debug` per method) · `api/app/api/routes/auth.py:560-575` (`update_memory` loop)
- **Severity:** Medium
- **Fix:** Cache `(user, fernet, tier)` on the middleware instance per request (it's constructed per call — add lazy `_ctx` loaded once). `update_memory`: add a `update_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 `db` per 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 when `backendClient.persistentWs?.readyState === OPEN` (expose a `isWsConnected()` getter); keep the HTTP health check only as fallback when WS down.
- **Risk:** None — `sendHomeRequest` already 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 next `dailyBrief` read or the existing scheduler slot tick. Delete `scheduleBriefRegeneration`.
- **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: true` to TanStackRouterVite in both configs. `React.lazy` + Suspense for `ChatChartBlock` (renders only on `<chart>` tag) and `MilkdownEditor`.
- **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` (contrast `web-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 `ScanDelta` into `startIndexSession` (skip re-walk). In the batch loop, check `ws.bufferedAmount` and await drain (or gate on `onProgress` acks) 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 config `api/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_overflow` and set explicit `pool_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_directory` executor result to include `mtimeMs`/`size` per entry (same `fs.stat` it already does for dirents — `drizzle-executor.ts:375-398`); backend reads mtimes from the listing, drops per-file `get_file_metadata` calls.
- **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 | None` lazy 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/offset` from TaskPager state; add `statusIn` filter server-side for the `active` pseudo-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 `handleSelect` does) to the `_all` page-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:** `useMemo` the HeaderContext value. Split ContextualChatContext into stable-actions context (`setScope`, `open`, `toggle`) and volatile stream context (`messages`, `streamingContent`, `isStreaming`); `useContextualScope` consumes only the stable one.
---
# 4. Dead Code
### DEAD-01 — Five dead Python deps · High (mechanical)
- **File:** `api/requirements.txt` lines 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:71` explicitly states it does NOT use google-auth-oauthlib).
- **Fix:** Delete those 5 lines. Run `pytest` after. Keep `python-dotenv` (pydantic-settings `env_file` needs it) and `google-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:** `Limiter` exported "for optional route-level decoration" — zero `@limiter` decorated 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 comments `adiuvAI/src/main/api/backend-client.ts:9,14`
- **Problem:** Electron client hits none of `POST /chat`, `/chat/brief`, `/chat/embed` (all traffic on `WS /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.py` portions that exercise them — keep middleware tests by retargeting another POST route). Either way, fix the stale doc comments in backend-client.ts (mentions nonexistent `WS /api/v1/chat/stream`) and `adiuvAI/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.tsx` is dead only because the barrel is — check `api/app/core/output_formatter.py` first: 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.ts` false-flagged (live via `web.html:11`).
- **Fix:** Add `"web.html"` and `"src/renderer/web-main.tsx"` to the `entry` array; optionally add ignore rule for TanStack `Route` exports. 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.0` with `langchain-core>=0.3.0` (only `langchain_core`/`langchain_openai`/`langchain_litellm` are imported). Drop explicit `websockets` pin (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: ERA001` if 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); keep `webPlatform` until 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`, `ruff` to a new `requirements-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 explicit `algorithms=[...]` 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:** Adopt `uv lock` or `pip-compile`; commit the lock; run `pip-audit` against 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`/`tar` via @electron-forge 7.x, `esbuild` via drizzle-kit — build-machine-only, **no fix available**. Do NOT run `npm 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.ts` holds the `t`/`router`/`publicProcedure` factory; `index.ts` becomes the `appRouter` merge + `AppRouter` export. Move brief helpers (L25-65) to `src/main/ai/brief-cache.ts` (with PERF-12).
- **Risk:** Low — routers are independent consts. Verify `AppRouter` type output unchanged (renderer compiles).
### QUAL-02 — Merge _run_single_agent twins ⚠️ REVIEW · High
- **File:** `api/app/core/deep_agent.py:873-1010` vs `: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. Use `contextlib.ExitStack` for 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 })`; plus `pickDefined(input, keys)` helper for update-set building. Prototype on `clients` first; domain procedures spread in.
- **Risk:** Generic factories can degrade tRPC type inference (`AppRouter` must stay exact for the renderer). If inference breaks, keep per-table routers and extract only `pickDefined` + create/update helpers.
### QUAL-05 — Split deep_agent.py (1329 LOC) · High
- **Fix:** (a) `app/core/prompt_context.py` ← `_*_injection` functions, `_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.py` keeps 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) extract `private dispatchFrame(frame)` + per-frame handlers from the 230-line `openDeviceWebSocket` (L876-1106); (c) `sendStreamFrame(frameType, payload, callbacks)` helper deduping the six `send*` methods (L276-660); (d) move `recordRunAction` (L55, SQLite write) to `src/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) -> RunStats` shared by the 210-line twins (`run_local_agent` 556-766 / `run_cloud_agent` 774-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.py` as protocol layer (endpoint, `_message_loop`, `_heartbeat_loop`, `_mark_runs_disconnected`); move the 9 `_handle_*` to `app/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 shared `ClientSelect` (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)) -> CloudScoutConfig` raising 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), zero `TRPCError`.
- **Fix:** Standardize on throwing `TRPCError` (ipcLink propagates, `ipcLink.ts:76`); migrate procedure-by-procedure WITH their renderer consumers (each `useQuery/useMutation` error handling). Coordinates with CORR-04/CORR-19. Do during QUAL-01 split.
- **Risk:** Every consumer touched — easy to miss one. Grep all `\.error` accesses on tRPC results.
### QUAL-12 — Typed WS frames server-side · Medium
- **File:** `api/app/api/routes/device_ws.py:424,620,704,767-820` (raw `json.dumps` dicts) 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_validate` at 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:25` reads `row.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.py` docstring + `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. extracting `link_or_create_oauth_user()` from the 124-line `oauth_callback`); `app/api/routes/scout_gmail_oauth.py` ← scouts.py L456-807. One `app/auth/state_store.py` `TTLStateStore` replacing both `_pending_states` dicts — 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.py` with `is_uuid()` (copies at task_agent.py:14-20, note_agent.py:15-22) and `format_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 type` so esbuild strips it without resolving; tsc confirms TS2307. Block data types are `any` throughout.
- **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.toml` with `[tool.mypy]`: start `check_untyped_defs = true`, `warn_return_any = true`; per-module strict for `app/core`, `app/api`. Wire into CI beside `ruff 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]`; `context` threaded through L43-592+)
- **Fix:** `RequestContext` TypedDict/pydantic model in `app/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 already `SQL[]`)
- **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.ts` with `declare 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
- `ToolResult` TypedDict in `api/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.py` cloud CRUD handlers (302, 313, 337, 366, 758), `device_ws.get_session_buffer:321` — add once TYPE-02's checker exists.
- `Provider` Protocol in `app/integrations/__init__.py` for `fetch_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; `SerializedTRPCResponse` in `src/shared/` closes the ipcLink seam (`ipcLink.ts:66,76`).
---
## Notes for the Executor
1. **Line numbers reference `main` @ `315c5d0`.** Re-verify each quoted snippet exists before editing; if it moved, find it by content, not line.
2. **Never execute ⚠️ REVIEW items unattended.** Security fixes especially: a sanitizer with a bypass or a broken auth check is worse than the original bug.
3. **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.
4. **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.
5. **Run `graphify update .` after each modifying session** (project rule).
6. 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).