Files
curo1305 fdc32d431d docs(03): create Phase 3 execution plan — document migration & multi-user isolation
5 plans across 5 sequential waves covering: Alembic migration 0003 (null-user
cleanup, NOT NULL constraint, quota reconciliation), presigned MinIO PUT upload
flow with atomic quota enforcement, auth guards on all document/topic endpoints,
flat-file settings retirement + per-user AI classification, and frontend quota bar
with 3-step XHR upload progress.

Verification passed across all 12 dimensions. All 8 phase requirements covered
(STORE-03/04/05/06, SEC-04, DOC-03/04/05).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 13:36:28 +02:00

26 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
03-document-migration-multi-user-isolation 04 execute 4
03-03
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
true
DOC-03
DOC-05
truths artifacts key_links
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
path provides contains
backend/config.py SYSTEM_PROMPT + DEFAULT_AI_PROVIDER + DEFAULT_AI_MODEL settings; SETTINGS_FILE / DEFAULT_SETTINGS / DEFAULT_SYSTEM_PROMPT removed default_ai_provider
path provides contains
backend/services/classifier.py classify_document accepts ai_provider, ai_model kwargs; _DEFAULT_SYSTEM_PROMPT module constant ai_provider
path provides contains
backend/services/storage.py load_settings/save_settings/mask_api_key/settings_masked removed load_topics_for_user
path provides contains
backend/tasks/document_tasks.py _run resolves user.ai_provider via session.get(User, doc.user_id) and passes to classifier user.ai_provider
path provides contains
backend/main.py api.settings router import + include_router removed settings_router
path provides
backend/api/settings.py File deleted
path provides contains
frontend/src/views/SettingsView.vue Admin-managed placeholder card; form/store/api removed managed by
path provides
frontend/src/stores/settings.js File deleted or gutted to empty placeholder
path provides contains
frontend/src/api/client.js getSettings/patchSettings/testProvider/getDefaultPrompt removed getMyQuota
from to via pattern
backend/tasks/document_tasks.py backend/db/models.py User user = await session.get(User, doc.user_id); ai_provider = user.ai_provider or settings.default_ai_provider user.ai_provider
from to via pattern
backend/services/classifier.py backend/ai/__init__.py get_provider _settings = {'active_provider': ai_provider, 'providers': {ai_provider: {'model': ai_model}}}; provider = get_provider(_settings) 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.

<execution_context> @/Users/nik/.claude/get-shit-done/workflows/execute-plan.md @/Users/nik/.claude/get-shit-done/templates/summary.md </execution_context>

@.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

<threat_model>

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
</threat_model>
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

Settings

Account-level options for your DocuVault workspace.

    <section class="bg-white border border-gray-200 rounded-xl p-6">
      <h3 class="text-xl font-semibold text-gray-800 mb-2">AI configuration</h3>
      <p class="text-sm text-gray-600">
        AI provider and model are managed by your administrator. Contact your admin
        to request changes to which AI provider is used for your documents.
      </p>
    </section>
  </div>
</template>

<script setup>
// SettingsView is a static placeholder after Phase 3 D-12 settings retirement.
// No store usage, no API calls — AI config is admin-only via /api/admin/users/{id}/ai-config.
</script>
```
Remove any `<style>` blocks if present in the original file.

Delete `frontend/src/stores/settings.js`. Before deleting, grep the frontend tree for imports of `'../stores/settings.js'` or `'@/stores/settings.js'`. If any consumer exists outside SettingsView.vue, instead gut the file to:
```js
// stores/settings.js was retired in Phase 3 D-12 — kept as a minimal no-op to avoid breaking imports.
// Remove this file in a future cleanup once all consumers are updated.
import { defineStore } from 'pinia'
export const useSettingsStore = defineStore('settings', () => {
  return {}
})
```
Otherwise delete the file.

Modify `frontend/src/api/client.js`:
1. Remove the four functions in the `// ── Settings ──` section: `getSettings`, `patchSettings`, `testProvider`, `getDefaultPrompt` (lines ~110-132). Remove the section header comment too.
2. Add a new function for the quota endpoint (used by Plan 03-05's QuotaBar). Place it under a new `// ── Quota ──` section (or under `// ── Auth ──`):
   ```js
   export function getMyQuota() {
     return request('/api/auth/me/quota')
   }
   ```
3. Add the upload-flow API helpers used by Plan 03-05:
   ```js
   export function getUploadUrl(filename, contentType) {
     return request('/api/documents/upload-url', {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ filename, content_type: contentType }),
     })
   }

   export function confirmUpload(documentId) {
     return request(`/api/documents/${documentId}/confirm`, { method: 'POST' })
   }
   ```
   Place these under the `// ── Documents ──` section near the existing `uploadDocument` function. Leave `uploadDocument` in place for now (Plan 03-05 will remove it when the documents store no longer calls it).
cd frontend && grep -c "managed by your administrator" src/views/SettingsView.vue && ! grep -q "useSettingsStore" src/views/SettingsView.vue && ! grep -q "getSettings\|patchSettings\|testProvider\|getDefaultPrompt" src/api/client.js && grep -c "getMyQuota\|getUploadUrl\|confirmUpload" src/api/client.js SettingsView.vue is the placeholder card (no form, no store). api/client.js has getMyQuota + getUploadUrl + confirmUpload exports and no getSettings/patchSettings/testProvider/getDefaultPrompt. stores/settings.js is deleted OR gutted to a no-op store. Visiting /settings in the dev server renders the card with no console errors (manual check during Plan 03-05 verification — not required here). - Settings endpoint removed: `cd backend && pytest tests/test_settings.py::test_settings_endpoint_removed -x -q` - Per-user AI classification: `cd backend && pytest 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` - No load_settings reference remains: `cd backend && grep -rn 'load_settings\|save_settings' backend --include='*.py' | grep -v tests/` returns no hits - Frontend settings imports clean: `cd frontend && grep -rn 'getSettings\|patchSettings\|testProvider\|getDefaultPrompt' src/` returns no hits - All Phase 3 tests still green: `cd backend && pytest -x -q`

<success_criteria>

  • backend/api/settings.py deleted
  • backend/main.py no longer registers settings_router
  • backend/services/storage.py no longer contains load_settings/save_settings/mask_api_key/settings_masked
  • backend/services/classifier.py classify_document accepts ai_provider and ai_model kwargs; reads no flat file
  • backend/tasks/document_tasks.py resolves user.ai_provider via DB lookup and passes to classifier
  • backend/config.py exposes system_prompt, default_ai_provider, default_ai_model fields and no longer defines SETTINGS_FILE/DEFAULT_SETTINGS/DEFAULT_SYSTEM_PROMPT
  • frontend/src/views/SettingsView.vue shows the admin-managed placeholder
  • frontend/src/api/client.js exports getMyQuota, getUploadUrl, confirmUpload and no longer exports settings functions
  • All Plan 03-01 stub tests for DOC-03 / DOC-05 / D-12 pass </success_criteria>
Create `.planning/phases/03-document-migration-multi-user-isolation/03-04-SUMMARY.md` when done — document the final classifier signature and the env var defaults; note that Phase 4 will keep /settings as a placeholder until a dedicated storage settings page lands.