---
phase: 07-redo-and-optimize-llm-integration
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - 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
autonomous: true
requirements:
  - D-04
  - D-05
  - D-07
  - D-09
  - D-10
  - D-11
  - D-12
  - D-13
  - D-14  # pre-satisfied — extra_hosts already present in docker-compose.yml; this plan adds a regression-guard grep in <verification>
  - D-16

must_haves:
  truths:
    - "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)"
  artifacts:
    - path: "backend/migrations/versions/0005_system_settings.py"
      provides: "system_settings table creation"
      contains: "system_settings"
    - path: "backend/db/models.py"
      provides: "SystemSettings ORM model"
      contains: "class SystemSettings"
    - path: "backend/services/ai_config.py"
      provides: "HKDF encryption helpers, ProviderConfig loader, startup seed"
      contains: "_derive_ai_settings_key"
    - path: "backend/tests/test_ai_providers.py"
      provides: "Wave 0 xfail stubs for D-01, D-03, D-06, D-07, D-12, D-13, D-16"
      contains: "pytest.xfail"
    - path: "backend/tests/test_ai_config.py"
      provides: "Wave 0 xfail stubs for D-04, D-05"
      contains: "pytest.xfail"
    - path: "backend/tests/test_admin_ai_config.py"
      provides: "Wave 0 xfail stubs for admin endpoint key-never-returned invariant"
      contains: "pytest.xfail"
  key_links:
    - from: "backend/services/ai_config.py::_derive_ai_settings_key"
      to: "backend/storage/cloud_utils.py::_derive_fernet_key"
      via: "HKDF pattern reuse with info=b\"ai-provider-settings\""
      pattern: "info=b\"ai-provider-settings\""
    - from: "backend/main.py lifespan"
      to: "backend/services/ai_config.py::seed_system_settings_from_env"
      via: "startup callback"
      pattern: "seed_system_settings_from_env"
    - from: "backend/migrations/versions/0005_system_settings.py"
      to: "backend/db/models.py::SystemSettings"
      via: "Schema/ORM alignment"
      pattern: "UniqueConstraint.*provider_id"
    - from: "docker-compose.yml backend service"
      to: "host.docker.internal:host-gateway extra_hosts entry"
      via: "Linux Docker host networking (D-14, pre-satisfied)"
      pattern: "host.docker.internal:host-gateway"
---

<objective>
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 <verification> so any future refactor that drops the entry fails the gate.
</objective>

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

<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

<interfaces>
<!-- Key contracts the executor needs - extracted from the codebase -->

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):
- "openai" -> {"base_url": None, "model": "gpt-4o", "context_chars": 120000}
- "anthropic" -> {"base_url": None, "model": "claude-sonnet-4-6", "context_chars": 180000}
- "gemini" -> {"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/", "model": "gemini-2.0-flash", "context_chars": 800000}
- "groq" -> {"base_url": "https://api.groq.com/openai/v1", "model": "llama-3.3-70b-versatile", "context_chars": 128000}
- "xai" -> {"base_url": "https://api.x.ai/v1", "model": "grok-3-mini", "context_chars": 128000}
- "deepseek" -> {"base_url": "https://api.deepseek.com", "model": "deepseek-chat", "context_chars": 60000}
- "openrouter" -> {"base_url": "https://openrouter.ai/api/v1", "model": "anthropic/claude-3.5-sonnet", "context_chars": 180000}
- "mistral" -> {"base_url": "https://api.mistral.ai/v1", "model": "mistral-large-latest", "context_chars": 128000}
- "ollama" -> {"base_url": "http://host.docker.internal:11434/v1", "model": "llama3.2", "context_chars": 8000}
- "lmstudio" -> {"base_url": "http://host.docker.internal:1234/v1", "model": "gemma-4-e4b-it", "context_chars": 8000}
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Wave 0 test scaffolds for D-01..D-16</name>
  <read_first>
    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
  </read_first>
  <behavior>
    - 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
  </behavior>
  <action>
    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.
  </action>
  <verify>
    <automated>cd backend &amp;&amp; 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>&amp;1 | grep -E "xfailed|passed|failed" | tail -3</automated>
  </verify>
  <acceptance_criteria>
    - 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
  </acceptance_criteria>
  <done>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.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Alembic migration 0005 + SystemSettings ORM model</name>
  <read_first>
    backend/migrations/versions/0004_phase4_pdf_open_mode_tsvector.py
    backend/migrations/versions/0001_initial_schema.py
    backend/db/models.py
  </read_first>
  <behavior>
    - 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
  </behavior>
  <action>
    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.
  </action>
  <verify>
    <automated>cd backend &amp;&amp; python -c "from db.models import SystemSettings; print(SystemSettings.__tablename__, SystemSettings.__table_args__)" &amp;&amp; ls migrations/versions/0005_system_settings.py</automated>
  </verify>
  <acceptance_criteria>
    - 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"
  </acceptance_criteria>
  <done>Migration file and ORM model are both committed; SystemSettings imports cleanly; migration revision chain is 0001→0002→0003→0004→0005.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 3: services/ai_config.py — HKDF helpers, ProviderConfig loader, env seed</name>
  <read_first>
    backend/storage/cloud_utils.py
    backend/config.py
    backend/db/models.py
    backend/main.py
    backend/services/storage.py
  </read_first>
  <behavior>
    - _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
  </behavior>
  <action>
    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.
  </action>
  <verify>
    <automated>cd backend &amp;&amp; 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')"</automated>
  </verify>
  <acceptance_criteria>
    - 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)
  </acceptance_criteria>
  <done>ai_config.py module exposes encryption + loader + seed; main.py invokes the seed on startup; round-trip test passes from CLI.</done>
</task>

</tasks>

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

<verification>
- 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).
</verification>

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

<output>
Create `.planning/phases/07-redo-and-optimize-llm-integration/07-01-SUMMARY.md` when done.
</output>
