--- phase: 03-document-migration-multi-user-isolation plan: 04 type: execute wave: 4 depends_on: - 03-03 files_modified: - backend/config.py - backend/services/classifier.py - backend/services/storage.py - backend/tasks/document_tasks.py - backend/api/settings.py - backend/main.py - backend/tests/test_settings.py - frontend/src/views/SettingsView.vue - frontend/src/stores/settings.js - frontend/src/api/client.js autonomous: true requirements: - DOC-03 - DOC-05 must_haves: truths: - "AI provider and model are resolved per document from users.ai_provider / users.ai_model via the Celery task (no flat-file read)" - "Falls back to settings.default_ai_provider / settings.default_ai_model env-var defaults when user.ai_provider is None" - "/api/settings endpoint no longer exists; backend/main.py does not register api.settings router" - "services.storage no longer exposes load_settings/save_settings/mask_api_key/settings_masked; settings.json is no longer read or written" - "classifier.classify_document accepts ai_provider and ai_model parameters; no longer reads global config from disk" - "SettingsView.vue displays an admin-managed message; the form is removed; stores/settings.js and api/client.js settings calls are removed" artifacts: - path: "backend/config.py" provides: "SYSTEM_PROMPT + DEFAULT_AI_PROVIDER + DEFAULT_AI_MODEL settings; SETTINGS_FILE / DEFAULT_SETTINGS / DEFAULT_SYSTEM_PROMPT removed" contains: "default_ai_provider" - path: "backend/services/classifier.py" provides: "classify_document accepts ai_provider, ai_model kwargs; _DEFAULT_SYSTEM_PROMPT module constant" contains: "ai_provider" - path: "backend/services/storage.py" provides: "load_settings/save_settings/mask_api_key/settings_masked removed" contains: "load_topics_for_user" - path: "backend/tasks/document_tasks.py" provides: "_run resolves user.ai_provider via session.get(User, doc.user_id) and passes to classifier" contains: "user.ai_provider" - path: "backend/main.py" provides: "api.settings router import + include_router removed" contains: "settings_router" - path: "backend/api/settings.py" provides: "File deleted" - path: "frontend/src/views/SettingsView.vue" provides: "Admin-managed placeholder card; form/store/api removed" contains: "managed by" - path: "frontend/src/stores/settings.js" provides: "File deleted or gutted to empty placeholder" - path: "frontend/src/api/client.js" provides: "getSettings/patchSettings/testProvider/getDefaultPrompt removed" contains: "getMyQuota" key_links: - from: "backend/tasks/document_tasks.py" to: "backend/db/models.py User" via: "user = await session.get(User, doc.user_id); ai_provider = user.ai_provider or settings.default_ai_provider" pattern: "user\\.ai_provider" - from: "backend/services/classifier.py" to: "backend/ai/__init__.py get_provider" via: "_settings = {'active_provider': ai_provider, 'providers': {ai_provider: {'model': ai_model}}}; provider = get_provider(_settings)" pattern: "active_provider.*ai_provider" --- Retire the flat-file settings system end-to-end (D-12, D-13) and wire per-user AI classification via DB lookup (D-14, D-15, DOC-03, DOC-05). Move system prompt + defaults to env vars in config.py. Delete /api/settings; refactor classifier to receive ai_provider/ai_model as parameters; update Celery task to look up doc.user_id → user.ai_provider/ai_model and pass through to classifier. Replace the frontend Settings view with an admin-managed placeholder; remove the settings store and client calls. Purpose: Phase 3 SC5 (each user's AI classification uses the provider/model assigned to that user by the admin) cannot pass while load_settings() exists. This is the final backend plan for Phase 3. Output: 10 file modifications (3 backend code, 1 backend route removal, 1 backend test update, 1 backend main.py, 3 frontend code, 1 backend config). After this plan, the entire Phase 3 backend is multi-user-isolated and admin-controlled. @/Users/nik/.claude/get-shit-done/workflows/execute-plan.md @/Users/nik/.claude/get-shit-done/templates/summary.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/03-document-migration-multi-user-isolation/03-CONTEXT.md @.planning/phases/03-document-migration-multi-user-isolation/03-RESEARCH.md @.planning/phases/03-document-migration-multi-user-isolation/03-PATTERNS.md @.planning/phases/03-document-migration-multi-user-isolation/03-VALIDATION.md @.planning/phases/03-document-migration-multi-user-isolation/03-03-SUMMARY.md @CLAUDE.md @backend/config.py @backend/services/classifier.py @backend/services/storage.py @backend/tasks/document_tasks.py @backend/api/settings.py @backend/main.py @backend/ai/__init__.py @backend/tests/test_settings.py @frontend/src/views/SettingsView.vue @frontend/src/stores/settings.js @frontend/src/api/client.js From backend/db/models.py: User.ai_provider: Mapped[Optional[str]] # nullable text — set by admin via PATCH /api/admin/users/{id}/ai-config User.ai_model: Mapped[Optional[str]] # nullable text From backend/ai/__init__.py (current — RESEARCH.md Finding 12 / A4): def get_provider(settings: dict) -> AIProvider # Accepts dict with keys: "active_provider": str, "providers": {: {"model": str, "api_key"?: str, "base_url"?: str}} # Branches on active_provider for anthropic / openai / ollama / lmstudio From backend/tasks/document_tasks.py (current): async def _run(document_id: str) -> dict — fetches Document by id — calls `topics = await classifier.classify_document(session, document_id)` # no ai_provider/ai_model yet From backend/services/classifier.py (post Plan 03-03): async def classify_document(session, doc_id, topic_names=None) -> list[str] — currently calls `settings = storage.load_settings(); provider = get_provider(settings)` — must change to `provider = get_provider({"active_provider": ai_provider, "providers": {ai_provider: {"model": ai_model}}})` From backend/config.py (current): SETTINGS_FILE = Path(settings.data_dir) / "settings.json" # to be removed DEFAULT_SYSTEM_PROMPT = "..." # to be removed (kept as module const in classifier.py) DEFAULT_SETTINGS = {...} # to be removed class Settings(BaseSettings): ... # extend with new fields ## Trust Boundaries | Boundary | Description | |----------|-------------| | user → /api/settings | Endpoint removed; previous attack surface eliminated | | Celery task → users table | Task resolves AI config via doc.user_id → users row; no user-controlled provider/model input | | admin → PATCH /api/admin/users/{id}/ai-config | Existing Phase 2 admin endpoint is the only write path to user.ai_provider / user.ai_model | ## STRIDE Threat Register | Threat ID | Category | Component | Disposition | Mitigation Plan | |-----------|----------|-----------|-------------|-----------------| | T-03-17 | Elevation of Privilege | User changing their own AI provider | mitigate | /api/settings is removed (D-12); only PATCH /api/admin/users/{id}/ai-config (admin-only) writes user.ai_provider/ai_model | | T-03-18 | Information Disclosure | settings.json flat file persisted to disk with API keys | mitigate | services.storage.load_settings/save_settings deleted; settings.json no longer read or written; API keys live in env vars only | | T-03-19 | Tampering | Celery task accepting ai_provider in task signature | mitigate | Task signature unchanged (just document_id); ai_provider/ai_model resolved INSIDE the task via DB lookup so a malicious broker message cannot inject provider | | T-03-20 | Information Disclosure | system_prompt env var in container logs | accept | SYSTEM_PROMPT is a static instruction string with no PII; documented as low-sensitivity | | T-03-21 | Repudiation | Frontend SettingsView still attempts old API calls | mitigate | Remove getSettings/patchSettings/testProvider/getDefaultPrompt from api/client.js; gut stores/settings.js; SettingsView shows static message — no API calls | | T-03-SC | Tampering | pip installs | mitigate | No new package installs | Task 1: Backend — retire settings flat-file; route classifier through per-user AI config backend/config.py, backend/services/classifier.py, backend/services/storage.py, backend/tasks/document_tasks.py, backend/api/settings.py, backend/main.py, backend/tests/test_settings.py - backend/config.py — Settings class fields, SETTINGS_FILE definition, DEFAULT_SYSTEM_PROMPT string, DEFAULT_SETTINGS dict - backend/services/storage.py — load_settings / save_settings / mask_api_key / settings_masked function bodies and __all__ entries (lines ~411-473) - backend/services/classifier.py — current settings load + get_provider call sites (lines 33-35 and 67-69) - backend/tasks/document_tasks.py — _run async body, particularly the classifier.classify_document call (line ~80) - backend/api/settings.py — the entire file (to be deleted) - backend/main.py — settings_router import + include_router call site - backend/ai/__init__.py — get_provider factory signature (RESEARCH.md A4 — verify factory accepts {"active_provider", "providers"} dict) - backend/tests/test_settings.py — existing test file with active tests (to be reduced to test_settings_endpoint_removed) - .planning/phases/03-document-migration-multi-user-isolation/03-RESEARCH.md — Finding 8 (Celery refactor), Finding 11 (retirement scope), Finding 12 (get_provider factory) - .planning/phases/03-document-migration-multi-user-isolation/03-PATTERNS.md — classifier signature (lines ~452-510), config.py additions (lines ~743-752) - config.py removes `SETTINGS_FILE`, `DEFAULT_SYSTEM_PROMPT`, `DEFAULT_SETTINGS` module-level constants - config.py Settings class gains `system_prompt: str = ""`, `default_ai_provider: str = "ollama"`, `default_ai_model: str = "llama3.2"` - services/classifier.py defines a module-level `_DEFAULT_SYSTEM_PROMPT` constant (the hardcoded string from old config.DEFAULT_SYSTEM_PROMPT). classify_document signature gains `ai_provider: str | None = None, ai_model: str | None = None` keyword params; substitutes config defaults when None; constructs `_settings = {"active_provider": ai_provider, "providers": {ai_provider: {"model": ai_model}}}` and calls `get_provider(_settings)`. NEVER calls `storage.load_settings()` - suggest_topics_for_document gets the same treatment (also gains ai_provider/ai_model kwargs and same fallback) - services/storage.py: removes `load_settings`, `save_settings`, `mask_api_key`, `settings_masked`; removes these from `__all__`; removes `from config import DEFAULT_SETTINGS, SETTINGS_FILE` and any `import json`, `import copy` lines that are no longer used; adds `from config import settings as app_settings` if not already present (Plan 03-04 may not need it, but classifier does) - tasks/document_tasks.py: in `_run` after fetching Document, fetch `user = await session.get(User, doc.user_id)`; compute `ai_provider = (user.ai_provider if user else None) or settings.default_ai_provider`, same for model; pass into `classifier.classify_document(session, document_id, ai_provider=ai_provider, ai_model=ai_model)`. Add `from config import settings` and `from db.models import User` deferred imports inside `_run` - backend/api/settings.py: file deleted - backend/main.py: removes `from api.settings import router as settings_router` and `app.include_router(settings_router)` - backend/tests/test_settings.py: replaces all existing tests with the single `test_settings_endpoint_removed` (the xfail stub from Plan 03-01) — keep file path. Mark previous tests as xfail or simply delete them; the only required test is the 404 assertion - GET /api/settings returns 404 (route no longer exists) - The Plan 03-01 stub tests test_per_user_provider, test_celery_task_uses_user_provider, test_default_provider_fallback, test_settings_endpoint_removed transition from xfail → pass Modify `backend/config.py`: 1. Remove `SETTINGS_FILE = Path(settings.data_dir) / "settings.json"` (line 60). 2. Remove the `DEFAULT_SYSTEM_PROMPT = """..."""` block (lines 62-67). 3. Remove the `DEFAULT_SETTINGS = {...}` dict (lines 69-91). 4. Inside the `Settings` class (alongside existing fields), add: ``` # AI classification defaults (Phase 3 — D-13, D-15) system_prompt: str = "" # SYSTEM_PROMPT env var; hardcoded fallback lives in classifier.py default_ai_provider: str = "ollama" # DEFAULT_AI_PROVIDER env var default_ai_model: str = "llama3.2" # DEFAULT_AI_MODEL env var ``` 5. Verify no module-level imports of `Path` or anything else remain unused after the deletions (clean up `from pathlib import Path` if no other consumers). Modify `backend/services/classifier.py`: 1. At module top, add `_DEFAULT_SYSTEM_PROMPT = """You are a document classification assistant..."""` with the verbatim string from the old `config.DEFAULT_SYSTEM_PROMPT`. 2. Add imports `import uuid` and `from db.models import Document` and `from config import settings as app_settings`. 3. Modify `classify_document` signature: ``` async def classify_document( session: AsyncSession, doc_id: str, topic_names: list[str] | None = None, ai_provider: str | None = None, ai_model: str | None = None, ) -> list[str]: ``` Replace `settings = storage.load_settings(); system_prompt = settings.get("system_prompt", ""); provider = get_provider(settings)` with: ``` _ai_provider = ai_provider or app_settings.default_ai_provider _ai_model = ai_model or app_settings.default_ai_model system_prompt = app_settings.system_prompt or _DEFAULT_SYSTEM_PROMPT _settings = { "active_provider": _ai_provider, "providers": {_ai_provider: {"model": _ai_model}}, } provider = get_provider(_settings) ``` 4. Modify `suggest_topics_for_document` with the same signature change (`ai_provider`, `ai_model` kwargs) and same `_settings` construction in place of `storage.load_settings()`. 5. Ensure no remaining `storage.load_settings()` call exists anywhere in this file. Modify `backend/services/storage.py`: 1. Remove the import `from config import DEFAULT_SETTINGS, SETTINGS_FILE` (line 36) — replace with nothing (config import is no longer needed in this module). 2. Remove `import copy` and `import json` from top imports (no longer needed). 3. Remove `load_settings()`, `save_settings()`, `mask_api_key()`, `settings_masked()` function definitions (lines ~416-449). 4. Remove these from `__all__`: `"load_settings", "save_settings", "mask_api_key", "settings_masked"`. Modify `backend/tasks/document_tasks.py` `_run` function: 1. After `doc = await session.get(Document, doc_uuid)` and the None check, insert: ``` from db.models import User from config import settings as app_settings user = await session.get(User, doc.user_id) if doc.user_id else None ai_provider = (user.ai_provider if user else None) or app_settings.default_ai_provider ai_model = (user.ai_model if user else None) or app_settings.default_ai_model ``` 2. Replace `topics = await classifier.classify_document(session, document_id)` with `topics = await classifier.classify_document(session, document_id, ai_provider=ai_provider, ai_model=ai_model)`. Delete `backend/api/settings.py` entirely. Use the `rm` shell equivalent or write an empty stub — preferable: delete the file. (Executor: use `git rm backend/api/settings.py` or `os.remove` then commit.) Modify `backend/main.py`: 1. Remove `from api.settings import router as settings_router` (line 18). 2. Remove `app.include_router(settings_router)` (line 174). Modify `backend/tests/test_settings.py`: 1. Delete all existing tests (they reference the removed endpoint and storage functions). 2. Keep only the single test from Plan 03-01: ``` async def test_settings_endpoint_removed(async_client): """D-12: /api/settings endpoint is removed in Phase 3.""" resp = await async_client.get("/api/settings") assert resp.status_code == 404 ``` Remove the `@pytest.mark.xfail` marker — this test should now pass green. cd backend && pytest tests/test_settings.py tests/test_classifier.py::test_per_user_provider tests/test_classifier.py::test_celery_task_uses_user_provider tests/test_classifier.py::test_default_provider_fallback -x -q 2>&1 | tail -30 && test ! -f backend/api/settings.py && echo "settings.py deleted" && ! grep -q "load_settings" backend/services/storage.py && echo "load_settings removed" && grep -c "default_ai_provider" backend/config.py && grep -c "_DEFAULT_SYSTEM_PROMPT" backend/services/classifier.py && grep -c "user.ai_provider" backend/tasks/document_tasks.py `backend/api/settings.py` file does not exist. `backend/services/storage.py` contains no `load_settings`, `save_settings`, `mask_api_key`, `settings_masked` (grep returns 0). `backend/config.py` contains `default_ai_provider` and `default_ai_model`. `backend/services/classifier.py` contains `_DEFAULT_SYSTEM_PROMPT` and accepts `ai_provider` kwarg. `backend/tasks/document_tasks.py` contains `user.ai_provider`. test_settings_endpoint_removed and the 3 classifier tests pass. Task 2: Frontend — strip settings store/API and replace SettingsView with admin-managed placeholder frontend/src/views/SettingsView.vue, frontend/src/stores/settings.js, frontend/src/api/client.js - frontend/src/views/SettingsView.vue — current form structure; replace entirely with placeholder card - frontend/src/stores/settings.js — actions to remove (fetchSettings, save, testConnection, resetPrompt) - frontend/src/api/client.js — getSettings, patchSettings, testProvider, getDefaultPrompt functions (lines ~110-132) - frontend/src/router/index.js — verify /settings route still exists (do not remove — UI-SPEC Risk 6 says keep route with placeholder) - .planning/phases/03-document-migration-multi-user-isolation/03-UI-SPEC.md — Phase 3 spec inherits Phase 2 design system; no specific SettingsView spec — use minimal card matching existing card style - .planning/phases/03-document-migration-multi-user-isolation/03-RESEARCH.md — Finding 11 (frontend retirement scope), Risk 6 (UX regression mitigation) - frontend/src/views/SettingsView.vue is replaced with a static placeholder: a heading "AI Configuration" and a card explaining "AI configuration is managed by your admin." Add a secondary link or text directing users to contact their admin. No form, no API calls, no useSettingsStore import - frontend/src/stores/settings.js is reduced to an empty defineStore that exports no actions, OR deleted entirely. Plan: delete the file and update any imports (SettingsView no longer imports it; check if anything else imports it — grep). If other files import it, gut the file to a minimal noop store - frontend/src/api/client.js removes the four functions: getSettings, patchSettings, testProvider, getDefaultPrompt (lines ~110-132 in current file). Adds a new export getMyQuota() that hits GET /api/auth/me/quota (used by QuotaBar in Plan 03-05 — pre-emptively add here so Plan 03-05 is a pure UI plan) - Visiting /settings in the browser shows the admin-managed placeholder without any console errors - SettingsView.vue uses only the Phase 2 design system (existing classes; text-sm / text-xl / bg-white / border-gray-200 / rounded-xl) — UI-SPEC inheritance Rewrite `frontend/src/views/SettingsView.vue` with the placeholder content. Template structure: ```vue ``` Remove any `