diff --git a/.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md b/.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
new file mode 100644
index 0000000..78259b0
--- /dev/null
+++ b/.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md
@@ -0,0 +1,950 @@
+# Phase 8: Stack Upgrade & Backend Decomposition — Research
+
+**Researched:** 2026-06-07
+**Domain:** FastAPI router decomposition, Python package decomposition, frontend API client decomposition, Vite 5→6 migration, requirements.txt pinning
+**Confidence:** HIGH
+
+---
+
+
+## User Constraints (from CONTEXT.md)
+
+### Locked Decisions
+
+**Phase 7.1 — Session Revocation (Wave 1)**
+- D-01: Phase 7.1 is absorbed into Phase 8 as the first wave.
+- D-02: Phase 7.1 scope: `revoke_all_refresh_tokens()` gets a `skip_token_hash` param; wired into `change_password`, `enable_totp`, and `disable_totp` with `sessions_revoked` field in each response shape; audit log entries written for each revocation.
+- D-03: Frontend toast implementation: create an empty `useToastStore` Pinia store stub during Phase 7.1. Phase 7.1 components (`SettingsAccountTab.vue`, `TotpEnrollment.vue`) call `toastStore.show(...)` when `sessions_revoked > 0`. Phase 10 fills in the full toast implementation — Phase 7.1 only creates the stub and wires the call sites.
+
+**Backend Router Decomposition**
+- D-04: Sub-routers MUST have NO prefix on `APIRouter()`. Parent prefix propagates. Any sub-router prefix causes doubled URL segments.
+- D-05: `api/admin/` split: `users.py`, `quotas.py`, `ai.py` — exact names locked.
+- D-06: `api/documents/` split: 4 sub-modules. Exact names are researcher/planner choice.
+- D-07: `api/auth/` split: 4 sub-modules (login/tokens, TOTP, password management, session management). Module names must mirror `services/auth.py` logical groupings.
+- D-08: Re-classify endpoint placement within `api/documents/` is researcher/planner choice.
+
+**Shared Pydantic Schemas (CODE-08)**
+- D-09: Pydantic models shared within a single package → `shared.py` inside that package.
+- D-10: Pydantic models referenced by 2+ packages → `backend/api/schemas.py`.
+- D-11: Validators currently defined inline in router files must migrate to appropriate `services/` module.
+
+**Frontend API Client Decomposition (CODE-04)**
+- D-12: `client.js` becomes HTTP transport layer + re-export barrel. `request()` and `noRefreshPaths` stay in `client.js`. Zero changes to any of the 35+ consumer files.
+- D-13: `fetchWithRetry()` (consolidating 3 blob-download 401-retry duplicates) lives in `frontend/src/api/utils.js`. `client.js` re-exports it.
+- D-14: Domain sub-modules: `documents.js`, `auth.js`, `admin.js`, `folders.js`, `shares.js`, `cloud.js`, `topics.js`.
+
+**Frontend Dependencies (PERF-01)**
+- D-15: Vite 5→6 is a major version bump requiring migration research.
+- D-16: Researcher determines which PERF-01 packages need config wiring vs install-only.
+
+**Backend Dependency Pinning**
+- D-17: `requirements.txt` must be converted from floating `>=` ranges to exact `==` pins using currently-installed versions. No version changes.
+
+### Claude's Discretion
+
+- Exact file names for `api/documents/` sub-modules
+- Exact file names for `api/auth/` sub-modules
+- Re-classify endpoint placement within `api/documents/`
+- Which validators in router files qualify for migration to `services/`
+- Domain grouping of each `client.js` function
+- Which PERF-01 packages need config wiring vs install-only
+
+### Deferred Ideas (OUT OF SCOPE)
+
+- Backend package version bumps (FastAPI, SQLAlchemy, etc.) — only exact-pin current versions
+- Composition API migration (Vue components)
+- Virtual scrolling, dark mode, folder reordering, multi-select batch ops
+
+
+---
+
+
+## Phase Requirements
+
+| ID | Description | Research Support |
+|----|-------------|------------------|
+| CR-01 | `change_password` revokes other sessions, returns `sessions_revoked` | ALREADY IMPLEMENTED in auth.py L518-536 — code exists, tests and toast needed |
+| CR-02 | `enable_totp` revokes other sessions, returns `sessions_revoked` | ALREADY IMPLEMENTED in auth.py L613-636 — code exists, tests and toast needed |
+| CR-03 | `disable_totp` revokes other sessions, returns `sessions_revoked` | ALREADY IMPLEMENTED in auth.py L659-682 — code exists, tests and toast needed |
+| CODE-01 | `api/admin.py` (934L) decomposed into `api/admin/` package | Sub-module boundaries identified below |
+| CODE-02 | `api/documents.py` (852L) decomposed into `api/documents/` package | Sub-module boundaries identified below |
+| CODE-03 | `api/auth.py` (825L) decomposed into `api/auth/` package | Sub-module boundaries identified below |
+| CODE-04 | `frontend/src/api/client.js` (635L) decomposed into domain modules | Function mapping identified below |
+| CODE-08 | No duplicated Pydantic models across router files; shared schemas extracted | Cross-package models identified below |
+| PERF-01 | Frontend deps bumped/added: vue@^3.5, vite@^6.4.3, @vueuse/core@^14.3, etc. | Migration analysis complete |
+
+
+---
+
+## Summary
+
+Phase 8 is a structural refactoring phase — zero behavior changes, all URL contracts preserved. The phase absorbs Phase 7.1 (session revocation) as Wave 1 before any decomposition work begins.
+
+**Critical finding on Wave 1 (CR-01, CR-02, CR-03):** Reading `backend/api/auth.py` confirms that all three session-revocation calls (`revoke_all_refresh_tokens` with `skip_token_hash`) are **already implemented** in the codebase at lines 518, 613, and 663. The `sessions_revoked` field is already in all three response shapes. The Wave 1 backend work is complete. What remains for Wave 1 is: (1) tests verifying the revocation behavior, and (2) the `useToastStore` stub + call-site wiring in `SettingsAccountTab.vue` and `TotpEnrollment.vue`. The frontend components already have inline `sessionRevokedToast` refs with 5s auto-dismiss — D-03 says to replace these with `toastStore.show(...)` calls and a stub store.
+
+**Critical finding on `CloudConnectionOut`:** This Pydantic model is defined in `api/admin.py` and imported by `api/cloud.py` at line 35. When `api/admin.py` is split into `api/admin/users.py`, `api/admin/quotas.py`, `api/admin/ai.py`, this import in `cloud.py` must update to the new location. Since `CloudConnectionOut` is used by 2+ packages (admin + cloud), it belongs in `api/schemas.py` per D-10.
+
+**Backend decomposition:** The three monoliths have clear natural boundaries based on reading their full source. Sub-module boundaries are documented below with exact line ranges.
+
+**Frontend decomposition:** 35+ consumer files import from `client.js` using three patterns: `import * as api`, named imports, and lazy `await import()`. The barrel re-export pattern preserves all patterns without touching consumer files.
+
+**Vite 5→6 migration:** For this project's `vite.config.js` (only `plugins`, `build.target`, `server.proxy`), the migration is low-risk. One potentially required change: `resolve.conditions` defaults changed. Since there is no custom `resolve.conditions` in this config, the default behavior change may or may not affect the project — the safest approach is to test after bump before committing.
+
+**Primary recommendation:** Execute in wave order — Wave 1 (CR tests + toast stub), Wave 2 (backend decomposition in parallel, CODE-04 independent), Wave 3 (pinning + PERF-01 deps).
+
+---
+
+## Architectural Responsibility Map
+
+| Capability | Primary Tier | Secondary Tier | Rationale |
+|------------|-------------|----------------|-----------|
+| Router decomposition | API layer (`backend/api/`) | None | Pure structural split; no service layer involvement |
+| Shared Pydantic schemas | API layer (`api/schemas.py`) | None | Response models live at API boundary |
+| Validators migration | Service layer (`services/`) | None | CLAUDE.md rule: validators raise ValueError, not HTTPException |
+| Frontend API decomposition | Frontend API layer (`src/api/`) | Stores (consumers) | Barrel pattern ensures stores see no change |
+| `useToastStore` stub | Frontend store layer (`src/stores/`) | Components (callers) | Pinia stores own application state |
+| requirements.txt pinning | Infrastructure | None | Reproducibility, no behavior change |
+| Vite 6 upgrade | Frontend build | None | Build tooling only; no runtime behavior change |
+
+---
+
+## Wave 1: Session Revocation (CR-01, CR-02, CR-03) — BACKEND ALREADY COMPLETE
+
+### What is already implemented
+
+Reading `backend/api/auth.py` confirms all three revocation calls exist:
+
+**`change_password` (L475-537):**
+- `skip_token_hash` computed from `request.cookies.get("refresh_token")` at L516-517
+- `revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)` at L518
+- `sessions_revoked` in response at L537: `return {"message": "Password updated", "sessions_revoked": revoked}`
+- Audit log with `metadata_={"sessions_revoked": revoked}` at L526
+
+**`enable_totp` (L579-636):**
+- `skip_token_hash` computed at L613-614
+- `revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)` at L615
+- `sessions_revoked` in response at L636: `return {"backup_codes": plain_codes, "sessions_revoked": revoked}`
+- Audit log at L619 with `metadata_={"sessions_revoked": revoked}`
+
+**`disable_totp` (L641-682):**
+- `skip_token_hash` computed at L660-661
+- `revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash)` at L662
+- `sessions_revoked` in response at L682: `return {"message": "TOTP disabled", "sessions_revoked": revoked}`
+- Audit log at L664 with `metadata_={"sessions_revoked": revoked}`
+
+`revoke_all_refresh_tokens` signature in `services/auth.py` (L250-273):
+```python
+async def revoke_all_refresh_tokens(
+ session: AsyncSession, user_id: uuid.UUID, skip_token_hash: Optional[str] = None
+) -> int:
+```
+The `skip_token_hash` parameter already exists and is already implemented correctly.
+
+### What Wave 1 actually needs to do
+
+1. **Tests only (backend):** Write 3 new tests confirming the revocation behavior:
+ - `test_change_password_revokes_other_sessions` — change password, verify other refresh tokens revoked, current session preserved
+ - `test_enable_totp_revokes_other_sessions`
+ - `test_disable_totp_revokes_other_sessions`
+
+2. **Frontend `useToastStore` stub:** Create `frontend/src/stores/toast.js` with a stub `show()` method (Phase 10 fills in the implementation). The stub must export `useToastStore` and accept the same `show(message, type)` call shape that Phase 10 will implement.
+
+3. **Frontend call-site rewiring:** In `SettingsAccountTab.vue` and `TotpEnrollment.vue`, replace the existing `sessionRevokedToast` ref + `setTimeout` pattern with `toastStore.show("Other sessions have been terminated.", "success")`. The existing inline toast HTML blocks in these two components can be removed since the stub store will handle display in Phase 10.
+
+### Existing inline toast locations
+
+`SettingsAccountTab.vue`:
+- Lines 1-25: inline toast HTML (fixed top-right, `v-if="sessionRevokedToast"`)
+- Line 211: `const sessionRevokedToast = ref(false)`
+- Lines 225-228: `changePassword` sets toast, 5s timeout
+- Lines 261-264: `disableTotp` sets toast, 5s timeout
+
+`TotpEnrollment.vue`:
+- Lines 1-23: inline toast HTML (inline, not fixed-position)
+- `sessionRevokedToast` ref and `setTimeout` in `confirmEnrollment` at line 174-177
+
+**Important:** The existing tests in `SettingsAccountTab.test.js` and `TotpEnrollment.test.js` already test for `sessions_revoked > 0` behavior and expect the toast text "Other sessions have been terminated." — these tests must still pass after the refactor, just via the store rather than the ref.
+
+---
+
+## Backend Router Decomposition
+
+### api/admin.py → api/admin/ package
+
+**Current structure (934L):**
+- Response helpers: `_ai_config_to_dict()`, `_user_to_dict()` (L58-91)
+- Pydantic models: `UserCreate`, `UserStatusUpdate`, `QuotaUpdate`, `UserAiConfigUpdate`, `SystemAiConfigUpdate`, `TestConnectionRequest`, `SystemTopicCreate`, `UserDeleteConfirm`, `CloudConnectionOut` (L96-223)
+- Endpoints by functional area:
+
+| Function | Endpoint | Target sub-module |
+|---|---|---|
+| `list_users`, `create_user`, `update_user_status`, `initiate_password_reset`, `delete_user` | `/api/admin/users*` | `users.py` |
+| `get_user_quota`, `update_user_quota` | `/api/admin/users/{id}/quota` | `quotas.py` |
+| `update_ai_config` (per-user) | `/api/admin/users/{id}/ai-config` | `users.py` |
+| `create_system_topic` | `/api/admin/topics` | `users.py` (admin utility) or `ai.py` |
+| `get_ai_config_models`, `test_ai_connection`, `get_ai_config`, `update_system_ai_config` | `/api/admin/ai-config*` | `ai.py` |
+
+**Recommended sub-module assignment:**
+
+`api/admin/users.py`: `list_users`, `create_user`, `update_user_status`, `initiate_password_reset`, `delete_user`, `update_ai_config` (per-user), `create_system_topic`
+`api/admin/quotas.py`: `get_user_quota`, `update_user_quota`
+`api/admin/ai.py`: `get_ai_config_models`, `test_ai_connection`, `get_ai_config`, `update_system_ai_config`
+
+**Pydantic model placement:**
+- `CloudConnectionOut` → `api/schemas.py` (used by both `api/admin/` and `api/cloud.py`)
+- `UserCreate`, `UserStatusUpdate`, `UserAiConfigUpdate`, `UserDeleteConfirm` → `api/admin/users.py` (only used in users.py)
+- `QuotaUpdate` → `api/admin/quotas.py` (only used in quotas.py)
+- `SystemAiConfigUpdate`, `TestConnectionRequest` → `api/admin/ai.py` (only used in ai.py)
+- `SystemTopicCreate` → `api/admin/users.py` (only used in create_system_topic)
+- `_user_to_dict()` helper → `api/admin/shared.py` (used by users.py and quotas.py both need user info)
+- `_ai_config_to_dict()` helper → `api/admin/ai.py` (only used by ai.py)
+
+**Validators that must migrate to services/ (D-11):**
+
+`UserCreate.password_strength` (L102-106): This `@field_validator` calls `validate_password_strength(v)` which already lives in `services/auth.py`. The validator itself is just a thin call-through — it is acceptable to keep it as-is in the Pydantic model since it delegates to the service. No migration needed for this one.
+
+`QuotaUpdate.must_be_positive` (L116-120): This `@field_validator` validates `limit_bytes > 0`. This is a Pydantic input validation rule (belongs at API boundary), NOT business logic. Keep in model per CLAUDE.md: "Pydantic `@field_validator` used for complex field constraints." No migration needed.
+
+`SystemAiConfigUpdate.provider_must_be_known` (L147-155) and `TestConnectionRequest.provider_must_be_known` (L172-179): These validate `provider_id` against `PROVIDER_DEFAULTS`. Since `PROVIDER_DEFAULTS` is an `ai/` layer concern, migrating this check to `services/ai_config.py` as `validate_provider_id(v: str) -> str` (raises `ValueError`) would be cleaner. The `@field_validator` in both models would then call `ai_config_service.validate_provider_id(v)`. This is the one migration that qualifies under D-11 since it's duplicated across two models.
+
+`DocumentPatch.filename_no_path_separators` (documents.py L83-88): Security validator — belongs in the Pydantic model at the API boundary. No migration needed.
+
+**Summary:** Only `provider_must_be_known` in `SystemAiConfigUpdate` + `TestConnectionRequest` qualifies for migration to `services/ai_config.py`. All other validators are appropriate Pydantic field constraints.
+
+### api/documents.py → api/documents/ package
+
+**Current structure (852L):**
+- Helper: `_parse_range()` (L744-760) — used only by `stream_document_content`
+- Constant: `_CLOUD_PROVIDERS` frozenset (L59)
+- Pydantic models: `UploadUrlRequest`, `DocumentPatch` (L65-88)
+
+| Endpoint | HTTP | Target sub-module |
+|---|---|---|
+| `request_upload_url` | POST /upload-url | `upload.py` |
+| `upload_document` | POST /upload | `upload.py` |
+| `confirm_upload` | POST /{id}/confirm | `upload.py` |
+| `list_documents` | GET / | `crud.py` |
+| `get_document` | GET /{id} | `crud.py` |
+| `patch_document` | PATCH /{id} | `crud.py` |
+| `delete_document` | DELETE /{id} | `crud.py` |
+| `classify_document` | POST /{id}/classify | `crud.py` |
+| `stream_document_content` | GET /{id}/content | `content.py` |
+
+**Recommended sub-module assignment:**
+
+`api/documents/upload.py`: `request_upload_url`, `upload_document`, `confirm_upload` — all deal with the presigned-URL + cloud upload flow
+`api/documents/crud.py`: `list_documents`, `get_document`, `patch_document`, `delete_document`, `classify_document` — standard document CRUD + re-classify
+`api/documents/content.py`: `stream_document_content`, `_parse_range` helper — content proxy + range header handling
+`api/documents/shared.py`: `_CLOUD_PROVIDERS` frozenset, `UploadUrlRequest`, `DocumentPatch` — shared by upload.py and crud.py
+
+Re-classify endpoint (`POST /api/documents/{id}/classify`) → `crud.py` (it operates on an existing document, same ownership check pattern as get/patch/delete).
+
+### api/auth.py → api/auth/ package
+
+**Current structure (825L):**
+
+| Endpoint | HTTP | Target sub-module |
+|---|---|---|
+| `register`, `login`, `refresh_token`, `logout`, `logout_all`, `get_me`, `get_my_quota` | `/api/auth/*` | `tokens.py` |
+| `totp_setup`, `enable_totp` | `/api/auth/totp/*` | `totp.py` |
+| `password_reset_request`, `password_reset_confirm` | `/api/auth/password-reset*` | `password.py` |
+| `change_password` | `/api/auth/change-password` | `password.py` |
+| `disable_totp` | `/api/auth/totp` (DELETE) | `totp.py` |
+| `get_my_preferences`, `update_my_preferences` | `/api/auth/me/preferences` | `tokens.py` (profile management) |
+
+**Recommended sub-module assignment:**
+
+`api/auth/tokens.py`: `register`, `login`, `refresh_token`, `logout`, `logout_all`, `get_me`, `get_my_quota`, `get_my_preferences`, `update_my_preferences` — token issuance, session management, profile
+`api/auth/totp.py`: `totp_setup`, `enable_totp`, `disable_totp` — TOTP lifecycle
+`api/auth/password.py`: `change_password`, `password_reset_request`, `password_reset_confirm` — password management
+`api/auth/shared.py`: `_set_refresh_cookie`, `_user_dict`, `RegisterRequest`, `LoginRequest`, `ChangePasswordRequest`, `TotpEnableRequest`, `PasswordResetRequest`, `PasswordResetConfirmRequest`, `PreferencesUpdate`, `limiter` (the Limiter instance) — shared across all sub-modules
+
+**JTI/ES256/fgp placement:** These live entirely in `services/auth.py` and `deps/auth.py` — not in the router files. No placement decision needed for decomposition.
+
+### main.py updates required
+
+Current imports from monoliths:
+```python
+from api.auth import limiter as auth_limiter # L21 — used for app.state.limiter
+from api.documents import router as documents_router # L22
+from api.auth import router as auth_router # L320
+from api.admin import router as admin_router # L321
+```
+
+After decomposition, each package's `__init__.py` aggregates sub-routers and exports `router`. `main.py` only changes to import from `api/auth/__init__.py`, `api/documents/__init__.py`, `api/admin/__init__.py`. The `limiter` from `api.auth` becomes `api.auth.shared.limiter` or re-exported from `api/auth/__init__.py`.
+
+### Circular import risk analysis
+
+**Risk 1: `api/cloud.py` imports `CloudConnectionOut` from `api/admin.py`**
+- Current: `from api.admin import CloudConnectionOut` (L35 of cloud.py)
+- After split: `api/admin/__init__.py` no longer directly defines it
+- Fix: Move `CloudConnectionOut` to `api/schemas.py` BEFORE splitting admin.py. Update cloud.py to `from api.schemas import CloudConnectionOut`. Both packages import from schemas — no circular dependency.
+
+**Risk 2: `backend/main.py` imports `limiter` from `api/auth.py`**
+- Current: `from api.auth import limiter as auth_limiter` (L21)
+- After split: `limiter` moves to `api/auth/shared.py`
+- Fix: `api/auth/__init__.py` re-exports `limiter`. `main.py` import unchanged.
+
+**Risk 3: `celery_app.py` import constraints**
+- Celery task modules never import from router modules (ARCHITECTURE.md constraint)
+- The new sub-packages must not be imported by `tasks/` or `celery_app.py`
+- The tasks deferred-import pattern (e.g., `from tasks.email_tasks import send_reset_email`) must stay as local imports inside function bodies, not at module top level
+
+**Risk 4: `tests/conftest.py` imports `auth_limiter`**
+- `backend/tests/conftest.py` L222: `from api.auth import limiter as auth_limiter`
+- After split: update to wherever `limiter` lands (via `api.auth` re-export, this stays unchanged)
+
+---
+
+## Frontend API Client Decomposition (CODE-04)
+
+### client.js function inventory and domain mapping
+
+**Functions that stay in `client.js` (transport layer):**
+- `request()` (L11-57) — HTTP transport, Bearer injection, 401-refresh-retry
+- `noRefreshPaths` (L26) — skip-refresh list
+
+**`frontend/src/api/utils.js` (new, per D-13):**
+The three blob-download 401-retry functions share identical structure — they bypass `request()` to return a raw `Response` instead of JSON. They should be consolidated into `fetchWithRetry(url, options, downloadHandler)`:
+
+1. `adminExportAuditLogCsv` (L428-471) — fetch CSV, 401-retry, Blob download
+2. `adminDownloadDailyExport` (L492-529) — fetch CSV, 401-retry, Blob download
+3. `fetchDocumentContent` (L552-581) — fetch content, 401-retry, return raw Response
+
+These three share identical authentication injection + 401-retry logic. The `fetchWithRetry` helper in `utils.js` handles the auth+retry boilerplate; each function provides its own `downloadHandler` or just returns the Response.
+
+**Domain sub-module assignments:**
+
+`api/documents.js`:
+- `listDocuments`, `getDocument`, `deleteDocument`, `deleteDocumentRemoveOnly`
+- `classifyDocument`, `getUploadUrl`, `confirmUpload`, `uploadToCloud`
+- `fetchDocumentContent`, `getDocumentContentUrl`
+
+`api/auth.js`:
+- `login`, `register`, `refreshToken`, `logout`, `logoutAll`, `getMe`
+- `changePassword`, `totpSetup`, `totpEnable`, `totpDisable`
+- `passwordResetRequest`, `passwordResetConfirm`
+- `getMyPreferences`, `updateMyPreferences`
+- `getMyQuota`
+
+`api/admin.js`:
+- `adminListUsers`, `adminCreateUser`, `adminDeactivateUser`, `adminReactivateUser`
+- `adminResetUserPassword`, `adminGetUserQuota`, `adminUpdateQuota`, `adminUpdateAiConfig`, `adminDeleteUser`
+- `getAiConfig`, `saveAiConfig`, `testAiConnection`, `getAiModels`
+- `adminListAuditLog`, `adminExportAuditLogCsv`, `adminListDailyExports`, `adminDownloadDailyExport`
+
+`api/folders.js`:
+- `listFolders`, `createFolder`, `getFolder`, `renameFolder`, `deleteFolder`, `moveDocument`
+
+`api/shares.js`:
+- `createShare`, `updateSharePermission`, `listShares`, `deleteShare`, `getSharedWithMe`
+
+`api/cloud.js`:
+- `listCloudConnections`, `disconnectCloud`, `connectWebDav`, `updateDefaultStorage`
+- `getCloudFolders`, `initiateOAuth`, `getConnectionConfig`
+
+`api/topics.js`:
+- `listTopics`, `createTopic`, `updateTopic`, `deleteTopic`, `suggestTopics`
+
+### Consumer import inventory
+
+35+ consumer files import from `client.js` using three patterns:
+
+**Pattern 1: `import * as api from '...api/client.js'`** (most common — namespace import)
+- `stores/auth.js`, `stores/documents.js`, `stores/folders.js`, `stores/topics.js`, `stores/cloudConnections.js`
+- `SettingsAccountTab.vue`, `SettingsPreferencesTab.vue`, `TotpEnrollment.vue`
+- `AppSidebar.vue`, `AuditLogTab.vue`, `AdminAiConfigTab.vue`, `AdminQuotasTab.vue`, `AdminUsersTab.vue`
+- `CloudProviderTreeItem.vue`, `CloudFolderTreeItem.vue`, `CloudCredentialModal.vue`
+- `FolderTreeItem.vue`, `DocumentView.vue`, `SharedView.vue`, `CloudFolderView.vue`, `AccountView.vue`
+- `NewPasswordView.vue`, `PasswordResetView.vue`
+
+**Pattern 2: Named imports `import { funcName } from '...api/client.js'`**
+- `SearchableModelSelect.vue`: `import { getAiModels }`
+- `SettingsCloudTab.vue`: `import { initiateOAuth }`
+- `AdminAiConfigTab.vue`: also `import { getAiConfig, saveAiConfig, testAiConnection }`
+- `DocumentCard.vue`: `import { moveDocument, classifyDocument }`
+- `DocumentPreviewModal.vue`: `import { fetchDocumentContent }`
+- `DocumentView.vue`: also `import { fetchDocumentContent }` (in addition to `* as api`)
+
+**Pattern 3: Lazy `await import('../stores/auth.js')`** (inside `request()` itself, stays in client.js)
+
+The barrel re-export in `client.js` must re-export every function from every domain module so that all three patterns continue to work unchanged:
+
+```javascript
+// client.js (after decomposition)
+// ... request() and noRefreshPaths stay here ...
+export * from './documents.js'
+export * from './auth.js'
+export * from './admin.js'
+export * from './folders.js'
+export * from './shares.js'
+export * from './cloud.js'
+export * from './topics.js'
+export { fetchWithRetry } from './utils.js'
+```
+
+---
+
+## PERF-01: Frontend Dependencies Analysis
+
+### Currently installed vs. required
+
+| Package | Currently installed | PERF-01 requires | Action |
+|---|---|---|---|
+| `vue` | 3.5.34 | `^3.5.0` | Already satisfies — no change |
+| `vite` | 5.4.21 | `^6.4.3` | Upgrade required |
+| `@vitejs/plugin-vue` | 5.2.4 | Must match Vite 6 | Upgrade to 6.x required |
+| `@vueuse/core` | not installed | `^14.3.0` | Install required |
+| `@vueuse/integrations` | not installed | `^14.3.0` | Install required |
+| `sortablejs` | not installed | `^1.15.7` | Install required |
+| `@tailwindcss/forms` | not installed | `^0.5.11` | Install required |
+| `rollup-plugin-visualizer` | not installed (dev) | `^7.0.1` | Install required (devDep) |
+| `@types/sortablejs` | not installed (dev) | latest | Install required (devDep) |
+
+[VERIFIED: npm registry] — all package versions confirmed via `npm view version`.
+
+### Vite 5→6 migration for this project
+
+[CITED: https://v6.vite.dev/guide/migration]
+
+**Breaking changes that affect this project's `vite.config.js`:**
+
+The current `vite.config.js` is minimal:
+```javascript
+export default defineConfig({
+ plugins: [vue()],
+ build: { target: 'esnext' },
+ server: { host: '0.0.0.0', port: 5173, proxy: { '/api': { target: 'http://backend:8000', changeOrigin: true } } },
+})
+```
+
+1. **`resolve.conditions` default values changed:** Defaults are no longer added internally for custom condition configs. Since this project has NO custom `resolve.conditions`, this change does NOT affect the project. No config change needed.
+
+2. **`json.stringify` defaults to `'auto'`:** This project does not configure `json.stringify`. The `'auto'` default (stringifies only large JSON files) is safe — no change needed.
+
+3. **CSS output filenames (library mode):** This is not a library mode build. No impact.
+
+4. **Sass modern API default:** This project uses Tailwind (PostCSS), not Sass. No impact.
+
+5. **`@vitejs/plugin-vue` must bump to 6.x:** Vite 6 requires `@vitejs/plugin-vue@^6.0.0`. The current `^5.0.0` is incompatible. [VERIFIED: npm registry] `@vitejs/plugin-vue@6.0.7` is the latest 6.x release.
+
+**Config changes required:**
+- Bump `vite` to `^6.4.3`
+- Bump `@vitejs/plugin-vue` to `^6.0.7`
+- No `vite.config.js` content changes required for this minimal config
+
+**Config changes NOT required:**
+- `build.target: 'esnext'` — still valid in Vite 6
+- `server.proxy` — unchanged
+- No `resolve.conditions` customization needed
+
+### @tailwindcss/forms: config wiring required
+
+Adding `@tailwindcss/forms` requires a one-line change to `tailwind.config.js`:
+```javascript
+import forms from '@tailwindcss/forms'
+export default {
+ content: ['./index.html', './src/**/*.{vue,js}'],
+ theme: { extend: {} },
+ plugins: [forms],
+}
+```
+This is used by VISUAL-02 in Phase 11. Installing in Phase 8 and wiring the plugin is the right time since VISUAL-02 needs it.
+
+### Package config wiring summary
+
+| Package | Install-only | Config wiring needed | Config file |
+|---|---|---|---|
+| `vue@^3.5.0` | Already installed | None | — |
+| `vite@^6.4.3` | Yes (with plugin-vue bump) | Minimal: test after install | `package.json` only |
+| `@vitejs/plugin-vue@^6.0.7` | Yes | None | `package.json` only |
+| `@vueuse/core@^14.3.0` | Yes | None | — |
+| `@vueuse/integrations@^14.3.0` | Yes | None | — |
+| `sortablejs@^1.15.7` | Yes | None | — |
+| `@tailwindcss/forms@^0.5.11` | No | Yes | `tailwind.config.js` |
+| `rollup-plugin-visualizer@^7.0.1` | No | Yes | `vite.config.js` (for PERF-02) |
+| `@types/sortablejs` | Yes (dev) | None | — |
+
+`rollup-plugin-visualizer` is needed for PERF-02 (bundle analysis) in Phase 11. Install now, wire in Phase 11 when measurements are taken.
+
+---
+
+## requirements.txt: Exact Pinning (D-17)
+
+**Current format:** All floating `>=` ranges.
+**Required format:** Exact `==` pins at currently-installed versions.
+
+Installed versions confirmed via `pip3 index versions`:
+
+| Package | Current constraint | Pin to |
+|---|---|---|
+| `fastapi` | `>=0.111` | `==0.128.8` |
+| `uvicorn[standard]` | `>=0.29` | [ASSUMED] — run `pip show uvicorn` to get exact |
+| `python-multipart` | `>=0.0.27` | [ASSUMED] — run `pip show python-multipart` |
+| `pydantic-settings` | `>=2.2` | [ASSUMED] — run `pip show pydantic-settings` |
+| `pydantic[email]` | `>=2.0` | [ASSUMED] — run `pip show pydantic` |
+| `anthropic` | `>=0.95.0` | `==0.104.0` (INSTALLED from pip3 output) |
+| `openai` | `>=1.30` | [ASSUMED] — run `pip show openai` |
+| `PyMuPDF` | `>=1.26.7` | [ASSUMED] — run `pip show PyMuPDF` |
+| `python-docx` | `>=1.1` | [ASSUMED] — run `pip show python-docx` |
+| `pytesseract` | `>=0.3` | [ASSUMED] — run `pip show pytesseract` |
+| `Pillow` | `>=10.3` | [ASSUMED] — run `pip show Pillow` |
+| `aiofiles` | `>=23.2` | [ASSUMED] — run `pip show aiofiles` |
+| `httpx` | `>=0.27` | [ASSUMED] — run `pip show httpx` |
+| `pytest` | `>=8.2` | [ASSUMED] — run `pip show pytest` |
+| `pytest-asyncio` | `>=1.3.0` | [ASSUMED] — run `pip show pytest-asyncio` |
+| `sqlalchemy[asyncio]` | `>=2.0.49` | `==2.0.49` (INSTALLED) |
+| `psycopg[binary]` | `>=3.3.4` | `==3.2.13` (INSTALLED — note: 3.2.x series) |
+| `alembic` | `>=1.18.4` | `==1.16.5` (INSTALLED — note: installed version lower than constraint) |
+| `minio` | `>=7.2.20` | [ASSUMED] — run `pip show minio` |
+| `celery[redis]` | `>=5.5.0` | `==5.6.3` (INSTALLED) |
+| `redis` | `>=4.6.0` | [ASSUMED] — run `pip show redis` |
+| `aiosqlite` | `>=0.20.0` | [ASSUMED] — run `pip show aiosqlite` |
+| `PyJWT` | `>=2.8.0` | `==2.13.0` (INSTALLED) |
+| `pwdlib[argon2]` | `>=0.2.1` | [ASSUMED] — run `pip show pwdlib` |
+| `pyotp` | `>=2.9.0` | [ASSUMED] — run `pip show pyotp` |
+| `slowapi` | `>=0.1.9` | [ASSUMED] — run `pip show slowapi` |
+| `cryptography` | `>=41.0.0` | `==48.0.0` (INSTALLED) |
+| `google-auth-oauthlib` | `>=1.3.1` | [ASSUMED] — run `pip show google-auth-oauthlib` |
+| `google-api-python-client` | `>=2.196.0` | [ASSUMED] — run `pip show google-api-python-client` |
+| `msal` | `>=1.36.0` | [ASSUMED] — run `pip show msal` |
+| `webdavclient3` | `>=3.14.7` | [ASSUMED] — run `pip show webdavclient3` |
+| `cachetools` | `>=5.3.0` | [ASSUMED] — run `pip show cachetools` |
+| `structlog` | `>=25.5.0` | `==25.5.0` (INSTALLED) |
+
+**Implementation:** The planner must include a task that runs `pip show | grep Version` for all [ASSUMED] packages to get exact versions, then writes the pinned `requirements.txt`.
+
+**Alembic discrepancy:** `requirements.txt` specifies `>=1.18.4` but `pip3 index versions` shows `1.16.5` installed. This is the installed version — D-17 says pin to currently installed. Pin to `==1.16.5`.
+
+---
+
+## Architecture Patterns
+
+### Package __init__.py aggregation pattern
+
+Every new package must have an `__init__.py` that aggregates sub-routers into a single exported `router` object with NO prefix:
+
+```python
+# api/admin/__init__.py
+from fastapi import APIRouter
+from api.admin.users import router as users_router
+from api.admin.quotas import router as quotas_router
+from api.admin.ai import router as ai_router
+
+router = APIRouter()
+router.include_router(users_router)
+router.include_router(quotas_router)
+router.include_router(ai_router)
+```
+
+The parent prefix is set in `main.py`:
+```python
+app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
+```
+
+Sub-routers have NO prefix — they just define routes without path prefix:
+```python
+# api/admin/users.py
+router = APIRouter() # NO prefix here
+
+@router.get("/users") # becomes /api/admin/users via parent
+async def list_users(...):
+ ...
+```
+
+**PITFALL:** `api/auth.py` currently sets `router = APIRouter(prefix="/api/auth", tags=["auth"])`. When decomposed, the parent `api/auth/__init__.py` must set the prefix, and sub-routers must have NO prefix.
+
+**PITFALL for api/auth:** Currently `main.py` imports `limiter` from `api.auth` for rate limit state. After decomposition, `api/auth/shared.py` defines the limiter, `api/auth/__init__.py` re-exports it: `from api.auth.shared import limiter`. main.py import `from api.auth import limiter as auth_limiter` continues to work.
+
+### api/schemas.py pattern
+
+New file for cross-package Pydantic models:
+```python
+# api/schemas.py
+from pydantic import BaseModel, field_validator
+from typing import Optional
+from datetime import datetime
+
+class CloudConnectionOut(BaseModel):
+ """Moved from api/admin.py — used by api/cloud.py and api/admin/."""
+ id: str
+ provider: str
+ display_name: str
+ status: str
+ connected_at: datetime
+ server_url: Optional[str] = None
+ connection_username: Optional[str] = None
+ model_config = {"from_attributes": True}
+
+ @field_validator("id", mode="before")
+ @classmethod
+ def coerce_id_to_str(cls, v) -> str:
+ return str(v)
+```
+
+**Update required in `api/cloud.py`:** Change `from api.admin import CloudConnectionOut` to `from api.schemas import CloudConnectionOut`.
+
+### Frontend barrel re-export pattern
+
+```javascript
+// src/api/client.js (after decomposition)
+async function request(path, options = {}) { /* unchanged */ }
+
+const noRefreshPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/refresh']
+
+// Re-export all domain modules — preserves all 3 consumer import patterns
+export * from './documents.js'
+export * from './auth.js'
+export * from './admin.js'
+export * from './folders.js'
+export * from './shares.js'
+export * from './cloud.js'
+export * from './topics.js'
+export { fetchWithRetry } from './utils.js'
+```
+
+Domain modules use `request` from a shared location. Since `request` is not exported from `client.js`, domain modules need access to it. Two options:
+1. Move `request` to `utils.js` and import from there in both `client.js` and domain files
+2. Each domain file imports `request` from `client.js` — but this creates a circular dependency if `client.js` re-exports from domain files
+
+**Correct approach (avoids circular imports):** Move `request` to `utils.js`. Domain files import from `utils.js`. `client.js` imports `request` from `utils.js` for its own internal use, and `export * from './domain.js'`.
+
+```javascript
+// src/api/utils.js
+export async function request(path, options = {}) { /* ... */ }
+export async function fetchWithRetry(url, onResponse, _retry = false) { /* consolidates 3 blob patterns */ }
+```
+
+```javascript
+// src/api/documents.js
+import { request } from './utils.js'
+export function listDocuments(...) { return request('/api/documents?...') }
+```
+
+```javascript
+// src/api/client.js
+import { request } from './utils.js' // for noRefreshPaths compatibility if needed
+export * from './documents.js'
+export * from './auth.js'
+// etc.
+```
+
+---
+
+## Don't Hand-Roll
+
+| Problem | Don't Build | Use Instead | Why |
+|---|---|---|---|
+| Python package aggregation | Custom router merger | Standard FastAPI `include_router` | Built-in, prefix-aware, tag-aware |
+| JS barrel re-export | Manually re-listing exports | `export * from './module.js'` | ES module spec, tree-shakeable |
+| Blob download with auth | New fetch wrapper | Consolidate into `fetchWithRetry` in `utils.js` | 3 existing implementations share identical auth logic |
+| requirements.txt pinning | pip freeze (wrong: includes dev deps) | `pip show ` per package | Avoids polluting with dev/transitive deps |
+
+---
+
+## Common Pitfalls
+
+### Pitfall 1: Sub-router prefix doubling
+
+**What goes wrong:** Adding `prefix="/api/admin"` to sub-router AND the parent `include_router` call doubles the URL: `/api/admin/api/admin/users`.
+**Why it happens:** Developers copy the existing `router = APIRouter(prefix="/api/admin", tags=["admin"])` pattern into sub-routers.
+**How to avoid:** Sub-routers must have `router = APIRouter()` with NO prefix. Only the `include_router` call in `main.py` carries the prefix.
+**Warning signs:** Test suite returns 404 on all decomposed endpoints.
+
+### Pitfall 2: Circular import via __init__.py
+
+**What goes wrong:** `api/admin/__init__.py` imports from `api/admin/users.py` which imports from `api/admin/__init__.py` (e.g., shared helpers).
+**Why it happens:** Putting shared helpers in `__init__.py` instead of `shared.py`.
+**How to avoid:** All shared helpers go in `api/admin/shared.py`. `__init__.py` only does router aggregation.
+**Warning signs:** `ImportError: cannot import name 'X' from partially initialized module`.
+
+### Pitfall 3: `api/cloud.py` import breaks after admin split
+
+**What goes wrong:** `api/cloud.py` imports `from api.admin import CloudConnectionOut`. After splitting, `CloudConnectionOut` is no longer in `api/admin/__init__.py`.
+**Why it happens:** Not updating `cloud.py` when moving `CloudConnectionOut` to `api/schemas.py`.
+**How to avoid:** Move `CloudConnectionOut` to `api/schemas.py` FIRST, update `cloud.py` import, verify tests pass, THEN split admin.py.
+
+### Pitfall 4: JS circular import (request in client.js)
+
+**What goes wrong:** Domain files import `request` from `client.js`, but `client.js` re-exports from domain files → circular dependency. Vite may silently produce undefined exports.
+**Why it happens:** Keeping `request` in `client.js` while also re-exporting from domain files that need `request`.
+**How to avoid:** Move `request` to `utils.js`. Domain files import from `utils.js`. `client.js` re-exports from domain files only.
+
+### Pitfall 5: ES module `export *` name collision
+
+**What goes wrong:** Two domain modules export a function with the same name (e.g., both `auth.js` and `admin.js` export `list()`). `export *` in `client.js` silently takes the last one.
+**Why it happens:** Generic function names in domain modules.
+**How to avoid:** All client.js functions already have unique, descriptive names (`listDocuments`, `adminListUsers`, etc.). Verify no name collisions before adding `export *`.
+
+### Pitfall 6: Vite 6 `@vitejs/plugin-vue` version mismatch
+
+**What goes wrong:** Running Vite 6 with `@vitejs/plugin-vue@^5.x` causes plugin incompatibility errors.
+**Why it happens:** package.json allows `^5.0.0` which cannot satisfy Vite 6's peer dependency.
+**How to avoid:** Bump both `vite` and `@vitejs/plugin-vue` in the same `npm install` command.
+
+### Pitfall 7: Wave 1 toast stub breaks existing tests
+
+**What goes wrong:** Replacing the `sessionRevokedToast` ref with `toastStore.show(...)` causes existing Vitest tests to fail because `useToastStore` is not mocked.
+**Why it happens:** Tests that mock `api.changePassword` returning `{sessions_revoked: 2}` now need the store to be available.
+**How to avoid:** The `useToastStore` stub must be importable and its `show()` must be a no-op by default (returns undefined). Tests that check for the toast text must be updated to check the store was called, or the stub renders nothing (which is fine for Phase 10).
+
+---
+
+## Code Examples
+
+### Package __init__.py with sub-router aggregation
+
+```python
+# Source: FastAPI docs — include_router pattern (ASSUMED standard pattern)
+from fastapi import APIRouter
+from api.admin.users import router as users_router
+from api.admin.quotas import router as quotas_router
+from api.admin.ai import router as ai_router
+
+router = APIRouter()
+router.include_router(users_router)
+router.include_router(quotas_router)
+router.include_router(ai_router)
+```
+
+### Sub-router file structure
+
+```python
+# Source: pattern from existing api/folders.py, api/shares.py (established in project)
+from __future__ import annotations
+from fastapi import APIRouter, Depends, HTTPException, Request
+from sqlalchemy.ext.asyncio import AsyncSession
+from deps.auth import get_current_admin
+from deps.db import get_db
+
+router = APIRouter() # NO prefix — parent sets it
+
+@router.get("/users") # Becomes /api/admin/users
+async def list_users(
+ session: AsyncSession = Depends(get_db),
+ _admin: User = Depends(get_current_admin),
+): ...
+```
+
+### useToastStore stub
+
+```javascript
+// Source: established Pinia pattern from auth.js, documents.js (project convention)
+import { defineStore } from 'pinia'
+
+export const useToastStore = defineStore('toast', () => {
+ function show(message, type = 'info') {
+ // Stub: Phase 10 fills in full implementation
+ // Phase 7.1 call sites use this; behavior visible after Phase 10
+ }
+ return { show }
+})
+```
+
+### fetchWithRetry consolidation
+
+```javascript
+// Consolidates adminExportAuditLogCsv, adminDownloadDailyExport, fetchDocumentContent
+export async function fetchWithRetry(url, options = {}, _retry = false) {
+ const { useAuthStore } = await import('../stores/auth.js')
+ const authStore = useAuthStore()
+
+ const headers = { ...(options.headers || {}) }
+ if (authStore.accessToken) {
+ headers['Authorization'] = `Bearer ${authStore.accessToken}`
+ }
+
+ const res = await fetch(url, { ...options, headers, credentials: 'include' })
+
+ if (res.status === 401 && !_retry) {
+ try {
+ await authStore.refresh()
+ return fetchWithRetry(url, options, true)
+ } catch {
+ authStore.accessToken = null
+ authStore.user = null
+ throw new Error('Session expired')
+ }
+ }
+
+ return res
+}
+```
+
+---
+
+## Package Legitimacy Audit
+
+slopcheck was not available in this environment (installation blocked by auto-mode classifier). All packages below are tagged [ASSUMED] per the graceful-degradation rule. The planner must gate each install behind a `checkpoint:human-verify` task.
+
+| Package | Registry | Purpose | Disposition |
+|---|---|---|---|
+| `@vueuse/core@^14.3.0` | npm | Vue composition utilities | [ASSUMED] — verify before install |
+| `@vueuse/integrations@^14.3.0` | npm | VueUse integrations | [ASSUMED] — verify before install |
+| `sortablejs@^1.15.7` | npm | Drag-and-drop sorting | [ASSUMED] — verify before install |
+| `@tailwindcss/forms@^0.5.11` | npm | Form base styles | [ASSUMED] — verify before install |
+| `rollup-plugin-visualizer@^7.0.1` | npm | Bundle analysis | [ASSUMED] — verify before install |
+| `@types/sortablejs` | npm | TypeScript types for sortablejs | [ASSUMED] — verify before install |
+| `vite@^6.4.3` | npm | Build tool (major upgrade) | [ASSUMED] — verify before install |
+| `@vitejs/plugin-vue@^6.0.7` | npm | Vue SFC compiler for Vite 6 | [ASSUMED] — verify before install |
+
+**Packages removed due to slopcheck [SLOP] verdict:** none (slopcheck unavailable)
+**Packages flagged as suspicious [SUS]:** none (slopcheck unavailable)
+
+*All packages above are tagged [ASSUMED] because slopcheck was unavailable at research time. The planner must gate each install behind a `checkpoint:human-verify` task.*
+
+---
+
+## Runtime State Inventory
+
+This is a refactoring phase — no renames, no migrations. This section is NOT applicable.
+
+---
+
+## Environment Availability
+
+| Dependency | Required By | Available | Version | Fallback |
+|------------|------------|-----------|---------|----------|
+| Python 3.12 | Backend | ✓ | 3.12 (Docker) | — |
+| Node.js 20 | Frontend | ✓ | 20 (Docker) | — |
+| npm | PERF-01 package installs | ✓ | bundled with Node | — |
+| pip3 | requirements.txt pinning | ✓ | system | — |
+| pytest | Backend tests | ✓ | installed per requirements.txt | — |
+| vitest | Frontend tests | ✓ | `^4.1.7` per package.json | — |
+
+**Missing dependencies with no fallback:** None.
+
+---
+
+## Validation Architecture
+
+### Test Framework
+
+| Property | Value |
+|----------|-------|
+| Backend framework | pytest with pytest-asyncio (asyncio_mode = auto) |
+| Backend config file | `backend/pytest.ini` |
+| Backend quick run | `cd backend && pytest tests/test_auth.py -x -v` |
+| Backend full suite | `cd backend && pytest -v` |
+| Frontend framework | Vitest 4.1.7 |
+| Frontend config | `frontend/vitest.config.js` |
+| Frontend quick run | `cd frontend && npm test` |
+
+### Phase Requirements → Test Map
+
+| Req ID | Behavior | Test Type | Automated Command | File Exists? |
+|--------|----------|-----------|-------------------|-------------|
+| CR-01 | `change_password` revokes other sessions, keeps current | unit/integration | `pytest tests/test_auth.py::test_change_password_revokes_other_sessions -x` | ❌ Wave 0 |
+| CR-02 | `enable_totp` revokes other sessions, keeps current | unit/integration | `pytest tests/test_auth.py::test_enable_totp_revokes_other_sessions -x` | ❌ Wave 0 |
+| CR-03 | `disable_totp` revokes other sessions, keeps current | unit/integration | `pytest tests/test_auth.py::test_disable_totp_revokes_other_sessions -x` | ❌ Wave 0 |
+| CODE-01 | All admin URLs still respond on same paths after split | integration | `pytest tests/test_admin.py -x` | ✅ |
+| CODE-02 | All document URLs still respond on same paths after split | integration | `pytest tests/test_documents.py -x` | ✅ |
+| CODE-03 | All auth URLs still respond on same paths after split | integration | `pytest tests/test_auth.py -x` | ✅ |
+| CODE-04 | All existing consumer imports resolve; no 35+ files changed | smoke | `cd frontend && npm test` | ✅ |
+| CODE-08 | No model defined twice | static/grep | `grep -rn "class CloudConnectionOut" backend/` | N/A |
+| PERF-01 | npm list confirms all packages present | install verification | `cd frontend && npm list` | N/A |
+
+### Wave 0 Gaps (new tests to write)
+
+- [ ] `backend/tests/test_auth.py` — add 3 new tests for CR-01/02/03:
+ - `test_change_password_revokes_other_sessions` — create 2 tokens, change password with skip_hash for token 1, verify token 2 revoked
+ - `test_enable_totp_revokes_other_sessions`
+ - `test_disable_totp_revokes_other_sessions`
+- [ ] Frontend: update `SettingsAccountTab.test.js` and `TotpEnrollment.test.js` to mock `useToastStore` after stub creation
+
+---
+
+## Security Domain
+
+### Applicable ASVS Categories
+
+| ASVS Category | Applies | Standard Control |
+|---------------|---------|-----------------|
+| V2 Authentication | yes (CR-01/02/03) | Session revocation on privilege change — already implemented |
+| V3 Session Management | yes | `revoke_all_refresh_tokens` with `skip_token_hash` — no change |
+| V4 Access Control | yes | `get_current_admin`, `get_regular_user` deps preserved through decomposition |
+| V5 Input Validation | yes | Pydantic models moved to `shared.py` — no change in validation logic |
+| V6 Cryptography | no | No crypto changes in this phase |
+
+### Known Threat Patterns
+
+| Pattern | Risk | Mitigation |
+|---------|------|------------|
+| Router prefix doubling | URL structure breaks, unintended route shadowing | Sub-routers MUST have no prefix (D-04) |
+| `CloudConnectionOut` import break | `cloud.py` fails to start; 500 on all cloud endpoints | Move to `api/schemas.py` BEFORE splitting admin.py |
+| Circular import crash | App fails to start entirely | `request()` moved to `utils.js`; shared.py not `__init__.py` |
+| Admin endpoint leakage | After split, a sub-module accidentally lacks `get_current_admin` dep | Every admin sub-module handler must explicitly inject `_admin: User = Depends(get_current_admin)` |
+
+---
+
+## Assumptions Log
+
+| # | Claim | Section | Risk if Wrong |
+|---|-------|---------|---------------|
+| A1 | Most [ASSUMED]-tagged pip package versions in pinning table | requirements.txt pinning | Wrong exact version pinned — planner must run `pip show` per package |
+| A2 | `fetchWithRetry` abstraction is sufficient to consolidate all 3 blob patterns | CODE-04 | Minor: may need separate functions if download triggers differ |
+| A3 | `export *` from domain modules has no name collisions | CODE-04 pitfalls | Low risk: all current client.js exports have unique names |
+| A4 | Vite 6 with `esnext` build target works without explicit `resolve.conditions` | PERF-01 | Low risk: project uses no custom conditions; test after upgrade |
+| A5 | `@vitejs/plugin-vue@^6.0.7` is the correct peer for `vite@^6.4.3` | PERF-01 | Low risk: npm will warn on peer dep mismatch at install time |
+
+**If this table is empty:** Not empty — A1 is the most significant: the planner must run `pip show` for all [ASSUMED] packages before writing pinned requirements.txt.
+
+---
+
+## Open Questions
+
+1. **`create_system_topic` placement in admin split**
+ - What we know: It's a one-line admin utility that calls `services.storage.create_topic()`
+ - What's unclear: Does it belong in `users.py` (as an admin utility) or `ai.py` (topics are AI-adjacent)?
+ - Recommendation: Place in `users.py` — topic creation is a content management function, not AI config.
+
+2. **`useToastStore` stub API shape**
+ - What we know: Phase 10 will implement the full toast system (UX-10). D-03 says Phase 7.1 only creates the stub with `show()`.
+ - What's unclear: Should `show()` accept `(message, type)` or `(message, options)`?
+ - Recommendation: `show(message, type = 'info')` — minimal two-arg API that Phase 10 can extend without breaking call sites.
+
+3. **alembic version discrepancy**
+ - What we know: `requirements.txt` says `>=1.18.4` but `pip3 index versions` shows installed `1.16.5`.
+ - What's unclear: Is `1.18.4` the required minimum or a typo?
+ - Recommendation: Pin to `==1.16.5` (the installed version). If `1.18.4` was intentional, note the mismatch for the user.
+
+---
+
+## Sources
+
+### Primary (HIGH confidence)
+- `backend/api/auth.py` — full source read, CR-01/02/03 implementation verified
+- `backend/api/admin.py` — full source read, sub-module boundaries identified
+- `backend/api/documents.py` — full source read, sub-module boundaries identified
+- `backend/services/auth.py` — `revoke_all_refresh_tokens` signature verified
+- `frontend/src/api/client.js` — full source read, all 635 lines, consumer mapping complete
+- `backend/main.py` — router registration pattern verified
+- `frontend/package.json` — current dependency versions verified
+- `frontend/vite.config.js` — current config verified (minimal)
+- `backend/requirements.txt` — current floating constraints verified
+- `.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md` — locked decisions
+- `pip3 index versions` output — installed package versions for fastapi, sqlalchemy, celery, alembic, pyjwt, anthropic, cryptography, structlog, psycopg
+
+### Secondary (MEDIUM confidence)
+- [CITED: https://v6.vite.dev/guide/migration] — Vite 5→6 breaking changes
+- `npm view vite version`, `npm view @vitejs/plugin-vue version` etc. — registry versions verified
+- `npm list` in frontend — installed frontend package versions
+
+### Tertiary (LOW confidence — marked [ASSUMED])
+- Remaining pip package installed versions (uvicorn, python-multipart, pydantic-settings, etc.) — not verified via `pip show`, only `pip3 index versions` for latest available
+
+---
+
+## Metadata
+
+**Confidence breakdown:**
+- CR-01/02/03 backend status: HIGH — source code read directly, implementation confirmed complete
+- Backend sub-module boundaries: HIGH — full source code read for all 3 monoliths
+- CloudConnectionOut cross-package risk: HIGH — import chain verified in cloud.py
+- Frontend function mapping: HIGH — full client.js read + all consumer imports grepped
+- Vite 5→6 migration impact: MEDIUM — official migration guide consulted, no Sass/library mode used
+- requirements.txt pinning: MEDIUM — some versions confirmed via pip3, most [ASSUMED]
+- Package legitimacy: LOW — slopcheck unavailable, all npm packages [ASSUMED]
+
+**Research date:** 2026-06-07
+**Valid until:** 2026-07-07 (stable refactoring domain; package versions may drift)