Files
kite/.planning/phases/07-redo-and-optimize-llm-integration/07-01-PLAN.md
T
curo1305andClaude Sonnet 4.6 3df62506c9 docs(07): create phase plan — 5 plans, verification passed
5 waves: system_settings DB + HKDF encryption (01), ProviderConfig +
GenericOpenAIProvider + singleton client fix (02), Anthropic output_config +
classifier wiring (03), Celery retry 30/90/270s + re-queue endpoint (04),
admin AI panel + DocumentCard badge + human checkpoint (05).

All 18 decisions D-01..D-18 covered. Plan checker passed after 1 revision
round (4 blockers fixed: D-02/D-14 coverage, D-11 Vitest tests, Plan 05
files_modified).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 18:25:00 +02:00

24 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
07-redo-and-optimize-llm-integration 01 execute 1
backend/migrations/versions/0005_system_settings.py
backend/db/models.py
backend/services/ai_config.py
backend/main.py
backend/tests/test_ai_providers.py
backend/tests/test_ai_config.py
backend/tests/test_admin_ai_config.py
backend/tests/test_document_tasks.py
true
D-04
D-05
D-07
D-09
D-10
D-11
D-12
D-13
D-14
D-16
truths artifacts key_links
Wave 0 test scaffold files exist with xfail stubs for every D-XX behavior listed in 07-VALIDATION.md
Running pytest backend/tests/ -v emits xfail markers for the new stubs and zero new failures
alembic upgrade head creates the system_settings table with provider_id UNIQUE constraint
load_provider_config(session) returns a ProviderConfig when an active row exists in system_settings
encrypt_api_key/decrypt_api_key round-trip a plaintext key using HKDF salt=provider_id, info=b"ai-provider-settings"
Backend startup seeds system_settings from env vars on first boot when no row exists for the env-configured default provider
docker-compose.yml continues to declare extra_hosts: host.docker.internal:host-gateway on both backend and celery-worker services (D-14 regression guard)
path provides contains
backend/migrations/versions/0005_system_settings.py system_settings table creation system_settings
path provides contains
backend/db/models.py SystemSettings ORM model class SystemSettings
path provides contains
backend/services/ai_config.py HKDF encryption helpers, ProviderConfig loader, startup seed _derive_ai_settings_key
path provides contains
backend/tests/test_ai_providers.py Wave 0 xfail stubs for D-01, D-03, D-06, D-07, D-12, D-13, D-16 pytest.xfail
path provides contains
backend/tests/test_ai_config.py Wave 0 xfail stubs for D-04, D-05 pytest.xfail
path provides contains
backend/tests/test_admin_ai_config.py Wave 0 xfail stubs for admin endpoint key-never-returned invariant pytest.xfail
from to via pattern
backend/services/ai_config.py::_derive_ai_settings_key backend/storage/cloud_utils.py::_derive_fernet_key HKDF pattern reuse with info=b"ai-provider-settings" info=b"ai-provider-settings"
from to via pattern
backend/main.py lifespan backend/services/ai_config.py::seed_system_settings_from_env startup callback seed_system_settings_from_env
from to via pattern
backend/migrations/versions/0005_system_settings.py backend/db/models.py::SystemSettings Schema/ORM alignment UniqueConstraint.*provider_id
from to via pattern
docker-compose.yml backend service host.docker.internal:host-gateway extra_hosts entry Linux Docker host networking (D-14, pre-satisfied) host.docker.internal:host-gateway
Lay the database, encryption, and test scaffolding foundation that every other Phase 7 plan depends on.

Purpose: Establish the single source of truth for AI provider configuration (system_settings table) with HKDF/Fernet encryption for API keys, mirror the proven cloud_utils.py pattern with domain-separated info bytes, and put Wave 0 xfail stubs in place so every later wave can promote red tests to green incrementally. Output: Alembic migration 0005, SystemSettings ORM model, backend/services/ai_config.py (encryption helpers + load_provider_config() + seed_system_settings_from_env), startup wiring, and four Wave 0 test files containing the xfail stubs enumerated in 07-VALIDATION.md.

D-14 is pre-satisfied (RESEARCH.md confirms extra_hosts already present in docker-compose.yml). This plan formally acknowledges D-14 by adding a regression-guard grep in so any future refactor that drops the entry fails the gate.

<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/07-redo-and-optimize-llm-integration/07-CONTEXT.md @.planning/phases/07-redo-and-optimize-llm-integration/07-RESEARCH.md @.planning/phases/07-redo-and-optimize-llm-integration/07-PATTERNS.md @.planning/phases/07-redo-and-optimize-llm-integration/07-VALIDATION.md @backend/storage/cloud_utils.py @backend/db/models.py @backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py @backend/main.py @backend/config.py @docker-compose.yml

From backend/storage/cloud_utils.py (HKDF pattern to mirror):

  • _derive_fernet_key(master_key: bytes, user_id: str) -> Fernet — uses salt=user_id.encode(), info=b"cloud-credentials"; creates FRESH HKDF instance every call (cryptography raises AlreadyFinalized on second .derive()).
  • encrypt_credentials(master_key: bytes, user_id: str, credentials: dict) -> str — JSON-encodes then Fernet-encrypts.
  • decrypt_credentials(master_key: bytes, user_id: str, credentials_enc: str) -> dict — Fernet-decrypts then JSON-decodes.

From backend/db/models.py (lines 299-318, CloudConnection):

  • Mapped[uuid.UUID] columns with mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
  • TIMESTAMP(timezone=True) columns with server_default=func.now()
  • __table_args__ carries Index/UniqueConstraint definitions

From backend/config.py (env var sources for seed):

  • settings.default_ai_provider (env: DEFAULT_AI_PROVIDER, default "ollama")
  • settings.default_ai_model (env: DEFAULT_AI_MODEL, default "llama3.2")
  • settings.cloud_creds_key (env: CLOUD_CREDS_KEY) — reused as master key for AI settings per D-05

From backend/main.py (lifespan):

  • Existing async lifespan context manager calls startup hooks before yield.

From docker-compose.yml (D-14 pre-satisfied):

  • backend service has extra_hosts: ["host.docker.internal:host-gateway"].
  • celery-worker service has extra_hosts: ["host.docker.internal:host-gateway"].
  • This plan adds a regression-guard grep — DO NOT remove either entry.

PROVIDER_DEFAULTS table (from 07-RESEARCH.md, also reused by Plan 02):

Task 1: Wave 0 test scaffolds for D-01..D-16 backend/tests/conftest.py backend/tests/test_classifier.py backend/tests/test_documents.py backend/tests/test_cloud_utils.py .planning/phases/07-redo-and-optimize-llm-integration/07-VALIDATION.md - test_ai_providers.py contains pytest.xfail stubs named: test_generic_openai_json_mode, test_anthropic_structured_output, test_get_provider_typed, test_client_singleton, test_context_chars_truncation, test_smart_truncation, test_gemini_fallback_to_parse_classification (covers D-02 — supports_json_mode=False path returns ClassificationResult via parse_classification) - test_ai_config.py contains pytest.xfail stubs named: test_load_provider_config, test_api_key_encrypt_decrypt - test_admin_ai_config.py contains pytest.xfail stubs named: test_get_never_returns_key, test_put_writes_active_provider - test_document_tasks.py contains pytest.xfail stubs named: test_retry_backoff, test_exhaustion_sets_failed_status; if file does not exist it must be created - test_documents.py gains a single pytest.xfail stub named: test_reclassify_requeues_celery - Running pytest backend/tests/ -v reports the new xfailed tests and zero new failures Create backend/tests/test_ai_providers.py, backend/tests/test_ai_config.py, backend/tests/test_admin_ai_config.py, and backend/tests/test_document_tasks.py (create new if missing — verify with ls first). Each test file imports pytest only and contains the test function names listed under behavior; each function body is a single line: pytest.xfail("not implemented yet — Plan 0X-Y") where the plan reference matches the wave that will promote it per 07-VALIDATION.md per-task-verification map. Mark every xfail stub with @pytest.mark.xfail(strict=False, reason="Wave 0 stub") above the function definition. Append the single test_reclassify_requeues_celery xfail stub to the bottom of the existing backend/tests/test_documents.py (do not rewrite the file). Pattern follows the "single-line body only" decision recorded in STATE.md for Wave 0 stubs. No assertion code in any stub. test_gemini_fallback_to_parse_classification is the D-02 coverage stub — Plan 02 Task 4 promotes it. cd backend && pytest tests/test_ai_providers.py tests/test_ai_config.py tests/test_admin_ai_config.py tests/test_document_tasks.py tests/test_documents.py -v 2>&1 | grep -E "xfailed|passed|failed" | tail -3 - Source assertion: backend/tests/test_ai_providers.py contains exactly seven stub functions named test_generic_openai_json_mode, test_anthropic_structured_output, test_get_provider_typed, test_client_singleton, test_context_chars_truncation, test_smart_truncation, test_gemini_fallback_to_parse_classification - Source assertion: backend/tests/test_ai_config.py contains test_load_provider_config and test_api_key_encrypt_decrypt - Source assertion: backend/tests/test_admin_ai_config.py contains test_get_never_returns_key and test_put_writes_active_provider - Source assertion: backend/tests/test_document_tasks.py contains test_retry_backoff and test_exhaustion_sets_failed_status - Source assertion: backend/tests/test_documents.py contains test_reclassify_requeues_celery - Source assertion: grep -c 'pytest.mark.xfail(strict=False' backend/tests/test_ai_providers.py returns 7 - Source assertion (D-14 regression guard): `grep -c 'host.docker.internal:host-gateway' docker-compose.yml` returns 2 - Behavior: pytest backend/tests/ -v reports 0 new failures attributable to these files All four scaffold files exist with the correct stub names; existing test_documents.py gained one stub; full suite xfail count grows by 13, failure count is unchanged. Task 2: Alembic migration 0005 + SystemSettings ORM model backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py backend/migrations/versions/0001_initial_schema.py backend/db/models.py - alembic upgrade head succeeds and creates the system_settings table - The table has columns: id (UUID PK, server_default gen_random_uuid()), provider_id (String NOT NULL UNIQUE), api_key_enc (Text NULL), base_url (Text NULL), model_name (Text NOT NULL default ''), context_chars (Integer NOT NULL default 8000), is_active (Boolean NOT NULL default false), created_at (TIMESTAMPTZ NOT NULL default now()), updated_at (TIMESTAMPTZ NOT NULL default now()) - UniqueConstraint on provider_id named uq_system_settings_provider_id is created - SystemSettings ORM model exposes the same columns with Mapped[] typed declarations - alembic downgrade -1 drops the table cleanly Create backend/migrations/versions/0005_system_settings.py with revision = "0005", down_revision = "0004", branch_labels = None, depends_on = None. Use the analog from 0004_phase4_pdf_open_mode_tsvector.py (imports, structure) but the body uses op.create_table("system_settings", ...) with sa.dialects.postgresql.UUID(as_uuid=True) for id, server_default=sa.text("gen_random_uuid()") for id, sa.Text for api_key_enc/base_url/model_name (model_name server_default=""), sa.Integer for context_chars (server_default="8000"), sa.Boolean for is_active (server_default="false"), sa.TIMESTAMP(timezone=True) for created_at/updated_at (server_default=sa.text("now()")), and sa.UniqueConstraint("provider_id", name="uq_system_settings_provider_id"). downgrade() calls op.drop_table("system_settings").
In backend/db/models.py append a `class SystemSettings(Base)` ORM model with __tablename__ = "system_settings". Mirror the CloudConnection column style (Mapped[uuid.UUID] id with mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)). provider_id: Mapped[str] = mapped_column(String, nullable=False, unique=True). api_key_enc: Mapped[str | None] = mapped_column(Text, nullable=True). base_url: Mapped[str | None] = mapped_column(Text, nullable=True). model_name: Mapped[str] = mapped_column(Text, nullable=False, default=""). context_chars: Mapped[int] = mapped_column(Integer, nullable=False, default=8000). is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False). created_at / updated_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False, server_default=func.now()). Add __table_args__ = (UniqueConstraint("provider_id", name="uq_system_settings_provider_id"),). Confirm all imports (Boolean, Integer, Text, UniqueConstraint, TIMESTAMP, func) are already present at the top of models.py — they are per 07-PATTERNS.md.
cd backend && python -c "from db.models import SystemSettings; print(SystemSettings.__tablename__, SystemSettings.__table_args__)" && ls migrations/versions/0005_system_settings.py - Source assertion: backend/migrations/versions/0005_system_settings.py exists and contains the literal strings `revision = "0005"`, `down_revision = "0004"`, `op.create_table("system_settings"`, `name="uq_system_settings_provider_id"`, `op.drop_table("system_settings")` - Source assertion: backend/db/models.py contains the string `class SystemSettings(Base)` and the string `__tablename__ = "system_settings"` - Source assertion: grep -c 'mapped_column' backend/db/models.py increases by at least 9 (one per SystemSettings column) - Behavior: `python -c "from db.models import SystemSettings"` exits 0 - Behavior: SystemSettings.__table_args__ contains a UniqueConstraint whose name equals "uq_system_settings_provider_id" Migration file and ORM model are both committed; SystemSettings imports cleanly; migration revision chain is 0001→0002→0003→0004→0005. Task 3: services/ai_config.py — HKDF helpers, ProviderConfig loader, env seed backend/storage/cloud_utils.py backend/config.py backend/db/models.py backend/main.py backend/services/storage.py - _derive_ai_settings_key(master_key, provider_id) creates a fresh HKDF(salt=provider_id.encode(), info=b"ai-provider-settings") on every call and returns a Fernet instance - encrypt_api_key(master_key, provider_id, api_key)/decrypt_api_key(master_key, provider_id, ciphertext) round-trip a plain string (no JSON wrap) - load_provider_config(session) returns a ProviderConfig built from the row where is_active=True, decrypting api_key_enc when present; returns None when no active row exists - seed_system_settings_from_env(session) inserts default rows for the env-configured default provider only when no row exists for that provider_id; never overwrites an existing row - Backend startup calls seed_system_settings_from_env inside the existing lifespan - HKDF info bytes differ from cloud_utils (`b"ai-provider-settings"` vs `b"cloud-credentials"`) — domain separation invariant verified by test Create backend/services/ai_config.py mirroring backend/storage/cloud_utils.py structure (module docstring explaining HKDF domain separation, AlreadyFinalized warning comment, imports of Fernet/HKDF/hashes from cryptography). Implement: `_derive_ai_settings_key(master_key: bytes, provider_id: str) -> Fernet` (fresh HKDF instance, salt=provider_id.encode("utf-8"), info=b"ai-provider-settings", length=32, sha256); `encrypt_api_key(master_key: bytes, provider_id: str, api_key: str) -> str` (Fernet.encrypt(api_key.encode()).decode()); `decrypt_api_key(master_key: bytes, provider_id: str, api_key_enc: str) -> str` (Fernet.decrypt(...).decode()); `async def load_provider_config(session: AsyncSession) -> Optional[ProviderConfig]` (select SystemSettings where is_active is True, decrypt api_key_enc using settings.cloud_creds_key bytes when not None, build ProviderConfig — note this depends on Plan 02 providing ProviderConfig; for now import lazily inside the function with a TYPE_CHECKING guard and a deferred import comment); `async def seed_system_settings_from_env(session: AsyncSession) -> None` (read settings.default_ai_provider/default_ai_model from config; SELECT one row WHERE provider_id = default; if missing INSERT a SystemSettings row with provider_id=default_ai_provider, model_name=default_ai_model, context_chars=8000, is_active=True, api_key_enc=None, base_url=None).
Because Plan 02 introduces ProviderConfig, define a minimal `class _ProviderConfigStub(BaseModel): provider_id: str; api_key: str = ""; base_url: str | None = None; model: str = ""; context_chars: int = 8000` inside ai_config.py for now, and add a module-level note `# ProviderConfig redefined in ai/provider_config.py during Plan 02 — load_provider_config will re-import and return that class once Plan 02 lands`. load_provider_config tries `from ai.provider_config import ProviderConfig` inside the function body and falls back to the stub if the import fails. This stub is removed in Plan 03 once classifier consumes the real ProviderConfig.

In backend/main.py register seed_system_settings_from_env inside the existing async lifespan: after the existing startup steps (and after the async session factory is available) acquire an AsyncSession via the existing session factory, await seed_system_settings_from_env(session), and await session.commit(). Wrap with try/except logging the error and continuing startup (do not crash boot if the table is missing during fresh container startup before migrations run — log a warning and skip). Import the function at the top of main.py.
cd backend && python -c "from services.ai_config import _derive_ai_settings_key, encrypt_api_key, decrypt_api_key, load_provider_config, seed_system_settings_from_env; mk=b'0'*32; ct=encrypt_api_key(mk,'openai','sk-test'); assert decrypt_api_key(mk,'openai',ct)=='sk-test'; print('round-trip OK')" - Source assertion: backend/services/ai_config.py contains the literal strings `info=b"ai-provider-settings"`, `salt=provider_id.encode("utf-8")`, `async def load_provider_config`, `async def seed_system_settings_from_env` - Source assertion: backend/services/ai_config.py does NOT contain the string `info=b"cloud-credentials"` (domain separation) - Source assertion: backend/main.py imports seed_system_settings_from_env and calls it inside the lifespan - Behavior: `python -c` round-trip above prints "round-trip OK" exactly - Behavior: encrypting the same plaintext with provider_id="openai" vs provider_id="anthropic" produces different ciphertexts (domain salt isolation) - Behavior: HKDF derivation never raises AlreadyFinalized when _derive_ai_settings_key is called twice consecutively (fresh instance per call) ai_config.py module exposes encryption + loader + seed; main.py invokes the seed on startup; round-trip test passes from CLI.

<threat_model>

Trust Boundaries

Boundary Description
admin browser → /api/admin/ai-config (planned Plan 05) admin-supplied API key crosses here; api_key_enc must never travel back
application → PostgreSQL system_settings ciphertext at rest; master key derived per-provider; never logged
HKDF master key (env CLOUD_CREDS_KEY) → derived Fernet keys shared master across cloud + AI; domain separated by info bytes

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-07-01 Information Disclosure services/ai_config.py decrypt path mitigate api_key returned only inside ProviderConfig consumed server-side; never serialized through API — Plan 05 enforces whitelist _ai_config_to_dict() that excludes api_key_enc
T-07-02 Elevation of Privilege HKDF key derivation mitigate info=b"ai-provider-settings" enforces domain separation from b"cloud-credentials"; same master key cannot derive the same Fernet for both domains — test enforces inequality
T-07-03 Tampering system_settings.is_active dual-write mitigate Plan 05 enforces single UPDATE SET is_active = (provider_id = $target) — atomic flip, no read-then-write
T-07-SC Tampering npm/pip installs accept No new packages added in Phase 7 — RESEARCH.md Package Legitimacy Audit confirms zero new deps; bandit + pip audit re-runs at phase gate
</threat_model>
- alembic upgrade head succeeds against a fresh PostgreSQL: `cd backend && alembic upgrade head` exits 0 and creates the system_settings table. - python -c smoke test in Task 3 prints "round-trip OK" — encryption round-trip verified. - pytest backend/tests/ -v adds 13 xfail tests with zero new failures. - grep -F 'info=b"ai-provider-settings"' backend/services/ai_config.py returns the helper line. - grep -F 'info=b"cloud-credentials"' backend/services/ai_config.py returns no matches (domain separation). - D-14 regression guard: `grep "host.docker.internal:host-gateway" docker-compose.yml | wc -l` outputs 2 (entries for both backend and celery-worker services; D-14 is pre-satisfied per RESEARCH.md — this guard prevents accidental removal).

<success_criteria>

  • system_settings table created and indexed (alembic head = 0005).
  • SystemSettings ORM model importable from backend.db.models.
  • backend/services/ai_config.py encrypt/decrypt round-trips; HKDF salt + info match RESEARCH.md spec.
  • Startup lifespan seeds default provider from env when row absent; idempotent across restarts.
  • Wave 0 test stubs exist in four files and reflect every D-XX listed in 07-VALIDATION.md.
  • pytest backend/tests/ -v shows new xfail count of 13 with zero new failures.
  • D-14 regression guard passes: docker-compose.yml retains both host-gateway entries. </success_criteria>
Create `.planning/phases/07-redo-and-optimize-llm-integration/07-01-SUMMARY.md` when done.