Files
kite/.planning/milestones/v0.2-phases/08-stack-upgrade-backend-decomposition/08-04-PLAN.md
T
curo1305andClaude Sonnet 4.6 123ae5b29b chore: archive v0.2 phase directories to milestones/v0.2-phases/
Moves phases 08–11 execution artifacts from .planning/phases/ to
.planning/milestones/v0.2-phases/ to keep .planning/phases/ clean
for the next milestone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 14:34:52 +02:00

21 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
phase plan type wave depends_on files_modified autonomous requirements tags must_haves
08-stack-upgrade-backend-decomposition 04 execute 2
08-02
08-03
backend/api/admin/__init__.py
backend/api/admin/users.py
backend/api/admin/quotas.py
backend/api/admin/ai.py
backend/api/admin/shared.py
backend/api/admin.py
backend/services/ai_config.py
true
CODE-01
CODE-08
backend-decomposition
admin
sub-router
truths artifacts key_links
backend/api/admin/ is a Python package (has __init__.py) with sub-modules users.py, quotas.py, ai.py, shared.py
All admin endpoints respond on the same URL paths as before (no doubled segments, no missing routes)
Every admin sub-router handler explicitly injects _admin: User = Depends(get_current_admin)
CloudConnectionOut is defined exactly once in the codebase (in api/schemas.py); the old definition in admin.py is deleted along with admin.py
No sub-router declares a prefix on APIRouter(); only api/admin/__init__.py carries prefix=/api/admin
validate_provider_id helper lives in services/ai_config.py; both SystemAiConfigUpdate and TestConnectionRequest call it
Old backend/api/admin.py monolith file is deleted (replaced by the package)
main.py import `from api.admin import router as admin_router` continues to work because api/admin/__init__.py re-exports router
path provides contains
backend/api/admin/__init__.py Router aggregator with prefix=/api/admin; includes users_router, quotas_router, ai_router router = APIRouter(prefix
path provides contains
backend/api/admin/users.py User CRUD + AI per-user config + create_system_topic handlers async def list_users
path provides contains
backend/api/admin/quotas.py Per-user quota GET + PATCH handlers async def get_user_quota
path provides contains
backend/api/admin/ai.py System AI config GET/PUT, test-connection, models endpoints async def get_ai_config
path provides contains
backend/api/admin/shared.py _user_to_dict helper shared between users.py and quotas.py def _user_to_dict
path provides contains
backend/services/ai_config.py validate_provider_id helper migrated from inline router validators per D-11 def validate_provider_id
from to via pattern
backend/main.py backend/api/admin/__init__.py from api.admin import router as admin_router from api.admin import router as admin_router
from to via pattern
backend/api/admin/__init__.py backend/api/admin/{users,quotas,ai}.py include_router calls router.include_router
from to via pattern
backend/api/admin/users.py and quotas.py backend/api/admin/shared.py from api.admin.shared import _user_to_dict from api.admin.shared import
Decompose the 934-line `backend/api/admin.py` monolith into a focused Python package `backend/api/admin/` containing `users.py`, `quotas.py`, `ai.py` per locked decision D-05. Move shared helpers to `shared.py`. Migrate the duplicated `provider_must_be_known` validator to `services/ai_config.py` per D-11. Delete the old monolith. Zero URL changes, zero behavior changes — `pytest tests/test_admin.py -x` must pass identically before and after.

Purpose: CODE-01 (decomposition) + CODE-08 (single definition of the provider_must_be_known rule, plus the already-completed CloudConnectionOut migration from plan 08-02).

Output: backend/api/admin/ package; one new helper in services/ai_config.py; backend/api/admin.py monolith file deleted.

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

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/08-stack-upgrade-backend-decomposition/08-CONTEXT.md @.planning/phases/08-stack-upgrade-backend-decomposition/08-RESEARCH.md @.planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md @backend/api/admin.py @backend/api/folders.py @backend/api/cloud.py @backend/api/schemas.py @backend/services/ai_config.py @backend/ai/provider_config.py @backend/main.py @CLAUDE.md Endpoint to sub-module map (locked per RESEARCH.md §"Recommended sub-module assignment"):

users.py: list_users (GET /users), create_user (POST /users), update_user_status (PATCH /users/{id}/status), initiate_password_reset (POST /users/{id}/password-reset), update_ai_config (PATCH /users/{id}/ai-config), delete_user (DELETE /users/{id}), create_system_topic (POST /topics) quotas.py: get_user_quota (GET /users/{id}/quota), update_user_quota (PATCH /users/{id}/quota) ai.py: get_ai_config_models (GET /ai-config/models), test_ai_connection (POST /ai-config/test-connection), get_ai_config (GET /ai-config), update_system_ai_config (PUT /ai-config)

Pydantic model to file map:

users.py: UserCreate, UserStatusUpdate, UserAiConfigUpdate, UserDeleteConfirm, SystemTopicCreate quotas.py: QuotaUpdate ai.py: SystemAiConfigUpdate, TestConnectionRequest

Helper map:

shared.py: _user_to_dict (used by users.py and quotas.py) ai.py: _ai_config_to_dict (used only by ai.py — keeps it local)

New helper to add (D-11 migration) in backend/services/ai_config.py:

def validate_provider_id(v: str) -> str: """Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule.""" if v not in PROVIDER_DEFAULTS: raise ValueError(f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}") return v

Package aggregator pattern for backend/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(prefix="/api/admin", tags=["admin"]) router.include_router(users_router) router.include_router(quotas_router) router.include_router(ai_router)

Sub-router pattern in each sub-module (D-04 — NO prefix on the sub-router):

router = APIRouter() # NO prefix — parent __init__.py carries it @router.get("/users") # becomes /api/admin/users via parent

Task 1: Add validate_provider_id helper to services/ai_config.py (D-11 migration) backend/services/ai_config.py - backend/services/ai_config.py (current contents — confirm PROVIDER_DEFAULTS is already imported at line 34) - backend/api/admin.py lines 147-179 (current inline validators in SystemAiConfigUpdate and TestConnectionRequest) - backend/ai/provider_config.py (confirm PROVIDER_DEFAULTS keys) Add a new top-level function `validate_provider_id(v: str) -> str` to `backend/services/ai_config.py`. The function MUST: (a) take a single string argument, (b) raise `ValueError(f"Unknown provider_id {v!r}. Must be one of: {list(PROVIDER_DEFAULTS.keys())}")` if `v not in PROVIDER_DEFAULTS`, (c) otherwise return `v` unchanged. Place the function after the existing `load_provider_config` helpers, before any HKDF helpers. PROVIDER_DEFAULTS is already imported at line 34 of the file. Add a single-line docstring: `"""Service-layer provider_id validator; raises ValueError per CLAUDE.md service-vs-API rule."""`. Do not modify any existing function in this file. cd backend && python -c "from services.ai_config import validate_provider_id; assert validate_provider_id('openai') == 'openai'; thrown=False try: validate_provider_id('not-a-provider') except ValueError as e: assert 'Unknown provider_id' in str(e); thrown=True assert thrown; print('ok')" - `grep -c "^def validate_provider_id" backend/services/ai_config.py` returns 1 - Running `python -c "from services.ai_config import validate_provider_id; validate_provider_id('openai')"` from `backend/` exits 0 - Running `python -c "from services.ai_config import validate_provider_id; validate_provider_id('xxx')"` from `backend/` exits non-zero with `ValueError` - No existing function in `backend/services/ai_config.py` was modified (verify by reading the file) validate_provider_id function exists, raises ValueError per CLAUDE.md service-layer rule, importable. Task 2: Create backend/api/admin/ package — shared.py, users.py, quotas.py, ai.py, __init__.py backend/api/admin/__init__.py, backend/api/admin/shared.py, backend/api/admin/users.py, backend/api/admin/quotas.py, backend/api/admin/ai.py - backend/api/admin.py lines 1-934 (full source — every endpoint, every Pydantic model, every helper) - backend/api/folders.py (analog sub-router structure, ownership check pattern, error handling style) - .planning/phases/08-stack-upgrade-backend-decomposition/08-PATTERNS.md sections for `backend/api/admin/*` (exact import lists, sub-router declaration, auth guard, ValueError-to-HTTPException bridge) - CLAUDE.md §"Backend: shared module map" (must not violate; helpers go in deps/utils.py / services/auth.py / api/admin/shared.py — never duplicated) This task creates the package `backend/api/admin/` and ALL five files in one logical unit. Execute in this order so the package is in a valid state on disk after each sub-step:

(A) Create backend/api/admin/shared.py first. Add from __future__ import annotations. Import User, SystemSettings from db.models. Define _user_to_dict(user: User) -> dict exactly per the body at backend/api/admin.py lines 75-91 (copy verbatim — same field set, same created_at.isoformat() handling, same docstring). The _ai_config_to_dict helper does NOT go in shared.py — it goes in ai.py because only ai.py uses it.

(B) Create backend/api/admin/users.py. Header: from __future__ import annotations. Imports per PATTERNS.md §"backend/api/admin/users.py" plus from api.admin.shared import _user_to_dict. Declare router = APIRouter() with NO prefix (D-04). Move these Pydantic models verbatim from backend/api/admin.py: UserCreate (lines 96-107), UserStatusUpdate (109-111), UserAiConfigUpdate (124-127), UserDeleteConfirm (190-195), SystemTopicCreate (182-187). Move these handler functions verbatim including decorators from backend/api/admin.py: list_users (228-243), create_user (245-316), update_user_status (318-383), initiate_password_reset (385-415), update_ai_config (494-534 — the per-user one), delete_user (536-637 — confirm exact end line by reading source), create_system_topic (639-662). Every handler keeps its _admin: User = Depends(get_current_admin) injection — never omit.

(C) Create backend/api/admin/quotas.py. Header + imports per PATTERNS.md §"backend/api/admin/quotas.py", plus from api.admin.shared import _user_to_dict. router = APIRouter() no prefix. Move QuotaUpdate Pydantic model (lines 113-121). Move handlers get_user_quota (417-439) and update_user_quota (441-492). Both inject _admin: User = Depends(get_current_admin).

(D) Create backend/api/admin/ai.py. Header + imports per PATTERNS.md §"backend/api/admin/ai.py". Add from services.ai_config import encrypt_api_key, load_provider_config_by_id, validate_provider_id (note the new validate_provider_id from task 1). router = APIRouter() no prefix. Move _ai_config_to_dict helper (lines 58-73) here as a module-level private helper (not in shared.py — only ai.py uses it). Move Pydantic models SystemAiConfigUpdate (129-155) and TestConnectionRequest (157-179). In both models, replace the inline provider_must_be_known validator body with return validate_provider_id(v) — the validator becomes a one-line call-through to the service-layer function (D-11). Move handlers get_ai_config_models (664-725), test_ai_connection (727-784), get_ai_config (786-824), update_system_ai_config (826-934). Every handler injects _admin: User = Depends(get_current_admin).

(E) Create backend/api/admin/__init__.py. Contents per PATTERNS.md §"backend/api/admin/__init__.py": import APIRouter from fastapi, import router as users_router, router as quotas_router, router as ai_router from the three sub-modules, declare router = APIRouter(prefix="/api/admin", tags=["admin"]), call router.include_router(users_router), router.include_router(quotas_router), router.include_router(ai_router). The __init__.py does ONLY router aggregation — no helpers, no models, no logic (Pitfall 2 prevention).

Do NOT delete backend/api/admin.py yet — that happens in task 3 after URL regression passes. Do NOT modify backend/main.py — the existing from api.admin import router as admin_router will continue to work after task 3 because the package's __init__.py exports router.

IMPORTANT URL regression: After creating the package, Python's import resolution prefers the package (directory with __init__.py) over the module (admin.py file) ONLY if both exist at the same level — but here they collide. To avoid an import ambiguity error, ensure the package directory is created and immediately rename backend/api/admin.py to backend/api/admin_OLD_REMOVE_IN_TASK_3.py as part of the same atomic edit. This sidelines the monolith without deleting it so task 3 can confirm tests pass and then delete the renamed file. cd backend && python -c "from api.admin import router; print('admin routes:', len(router.routes))" && python -c "from api.admin.users import router as r; print('users routes:', len(r.routes))" && python -c "from api.admin.quotas import router as r; print('quotas routes:', len(r.routes))" && python -c "from api.admin.ai import router as r; print('ai routes:', len(r.routes))" <acceptance_criteria> - Files exist: backend/api/admin/__init__.py, backend/api/admin/shared.py, backend/api/admin/users.py, backend/api/admin/quotas.py, backend/api/admin/ai.py - grep -v '^#' backend/api/admin/__init__.py | grep -c 'router = APIRouter(prefix="/api/admin"' returns 1 - grep -v '^#' backend/api/admin/users.py | grep -c 'router = APIRouter()' returns 1 (NO prefix) - grep -v '^#' backend/api/admin/quotas.py | grep -c 'router = APIRouter()' returns 1 (NO prefix) - grep -v '^#' backend/api/admin/ai.py | grep -c 'router = APIRouter()' returns 1 (NO prefix) - grep -c "Depends(get_current_admin)" backend/api/admin/users.py ≥ 7 (one per handler) - grep -c "Depends(get_current_admin)" backend/api/admin/quotas.py ≥ 2 - grep -c "Depends(get_current_admin)" backend/api/admin/ai.py ≥ 4 - grep -c "return validate_provider_id(v)" backend/api/admin/ai.py returns 2 (SystemAiConfigUpdate + TestConnectionRequest) - cd backend && python -c "from api.admin import router; assert len(router.routes) >= 13" exits 0 (total: 7 users + 2 quotas + 4 ai = 13) - cd backend && python -c "from main import app; routes = [r.path for r in app.routes]; assert '/api/admin/users' in routes" 2>&1 | tail -3 shows no AssertionError </acceptance_criteria> Package exists with all five files; sub-router count totals 13; no sub-router carries a prefix; main.py imports still resolve; admin.py monolith is renamed but not yet deleted.

Task 3: Run URL regression suite, then delete the old monolith backend/api/admin.py - backend/tests/test_admin.py (full file — confirm test surface) - backend/api/admin_OLD_REMOVE_IN_TASK_3.py (the renamed monolith from task 2) Run `cd backend && pytest tests/test_admin.py tests/test_cloud.py tests/test_admin_ai_config.py -x -v` to confirm every admin URL still responds correctly (URLs unchanged, response shapes unchanged, auth guards still effective). If ANY test fails, do NOT delete the renamed monolith — diagnose, fix, and re-run. Only after all admin + admin_ai_config + cloud tests pass: delete `backend/api/admin_OLD_REMOVE_IN_TASK_3.py` (the renamed monolith from task 2). The old file is now superseded by the package. Re-run the full backend suite `cd backend && pytest -v` to confirm no other tests regressed. cd backend && pytest tests/test_admin.py tests/test_cloud.py tests/test_admin_ai_config.py -x -v 2>&1 | tail -15 && test ! -f backend/api/admin.py && test ! -f backend/api/admin_OLD_REMOVE_IN_TASK_3.py && echo "old monolith deleted" - `test ! -f backend/api/admin.py` exits 0 (monolith deleted) - `test ! -f backend/api/admin_OLD_REMOVE_IN_TASK_3.py` exits 0 (renamed monolith also deleted) - `test -d backend/api/admin` exits 0 (package directory exists) - `cd backend && pytest tests/test_admin.py -x` exits 0 - `cd backend && pytest tests/test_cloud.py -x` exits 0 (because plan 08-02 already migrated CloudConnectionOut to api/schemas.py) - `cd backend && pytest -v` exits 0 with no new failures vs. baseline before this plan - `grep -rn "class CloudConnectionOut" backend/` returns exactly one match — `backend/api/schemas.py` - `grep -rn "^class UserCreate" backend/api/admin/` returns exactly one match (in users.py); same single-definition check for `QuotaUpdate`, `SystemAiConfigUpdate`, `TestConnectionRequest` Old admin.py file deleted; admin/cloud/ai-config test suites pass; full backend suite green; each Pydantic model defined exactly once.

<threat_model>

Trust Boundaries

Boundary Description
Unauthenticated/regular user → admin endpoints All sub-router handlers must enforce get_current_admin to prevent privilege escalation
Admin endpoint → CloudConnectionOut serialization SEC-08: credentials_enc must remain absent from the schema after the move
Admin endpoint → response body T-02-27 / SEC-07: _user_to_dict must continue to exclude password_hash, credentials_enc, totp_secret, document content

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-08-04-01 Spoofing/Elevation of Privilege Admin sub-router auth gate mitigate Every handler in users.py / quotas.py / ai.py declares _admin: User = Depends(get_current_admin). Acceptance criterion counts these injections.
T-08-04-02 Tampering Sub-router prefix doubling mitigate Sub-routers declare router = APIRouter() with NO prefix per D-04; only __init__.py carries prefix="/api/admin". Acceptance criterion greps for this.
T-08-04-03 Information Disclosure _user_to_dict field set mitigate Helper copied verbatim from admin.py lines 75-91; existing tests covering admin user list endpoint guard against accidental field inclusion.
T-08-04-04 Denial of Service Circular import via __init__.py mitigate __init__.py does ONLY aggregation; _user_to_dict lives in shared.py not __init__.py (Pitfall 2).
T-08-04-05 Tampering Validator duplication mitigate provider_must_be_known migrated to services/ai_config.py as validate_provider_id; both Pydantic models call the same service helper (D-11).
T-08-04-06 Information Disclosure Old admin.py left in place mitigate Task 3 deletes the file only after URL regression tests pass; renamed file as an intermediate state cannot be imported because no code imports from api.admin_OLD_REMOVE_IN_TASK_3.
T-08-04-SC Supply Chain No new packages accept This plan installs zero new packages.
</threat_model>
- `cd backend && pytest tests/test_admin.py tests/test_admin_ai_config.py tests/test_cloud.py -x -v` — zero failures - `cd backend && pytest -v` — full suite zero failures - `grep -rn "from api.admin import CloudConnectionOut" backend/` returns no matches - `grep -rn "class CloudConnectionOut" backend/` returns exactly one match (api/schemas.py) - `cd backend && python -c "from main import app; admin_paths = sorted({r.path for r in app.routes if r.path.startswith('/api/admin')}); print('\\n'.join(admin_paths))"` lists at minimum: `/api/admin/users`, `/api/admin/users/{user_id}/status`, `/api/admin/users/{user_id}/quota`, `/api/admin/users/{user_id}/ai-config`, `/api/admin/users/{user_id}/password-reset`, `/api/admin/users/{user_id}`, `/api/admin/topics`, `/api/admin/ai-config`, `/api/admin/ai-config/models`, `/api/admin/ai-config/test-connection`

<success_criteria>

  • backend/api/admin/ package with 5 files
  • backend/api/admin.py deleted
  • All admin URL paths unchanged
  • CODE-08: CloudConnectionOut + provider_must_be_known single-defined
  • All tests pass </success_criteria>
Create `.planning/phases/08-stack-upgrade-backend-decomposition/08-04-SUMMARY.md` when done. Include: (a) full list of admin paths emitted by `app.routes` before vs. after (must be identical), (b) exact admin/cloud/ai-config test counts pre vs. post, (c) confirmation that `_admin: User = Depends(get_current_admin)` count matches the number of handlers per module.