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

91 KiB
Raw Blame History

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.tshandleListDirectory 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.tshandleInsert 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.tscreateWindow 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:
    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 tonew 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-1915attachments/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.
  • 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.tsprojects.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:68undefined 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 ToolMessages 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,721orchestrator.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.pyBaseAgent, 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).