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>
296 lines
24 KiB
Markdown
296 lines
24 KiB
Markdown
---
|
|
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 && 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</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 && python -c "from db.models import SystemSettings; print(SystemSettings.__tablename__, SystemSettings.__table_args__)" && 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 && 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>
|