---
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 configbackend/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 placeholderfrontend/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
Settings
Account-level options for your DocuVault workspace.
AI configuration
AI provider and model are managed by your administrator. Contact your admin
to request changes to which AI provider is used for your documents.