---ci---
phase: 2
milestone: v0.3
status: verify
requirements:
covered: [REQ-72]
partial: []
lessons:
- pydantic model_copy skips validation: an explicit-null settings field
passed the route's exclude_unset merge, returned 200, and poisoned
the on-disk payload into a corrupt file that 500s every later GET/PUT/
create_run with no API recovery path (self-inflicted DoS).
---/ci---
verify(P02) P0 fix: PUT /api/config/settings {"planner_model": null}
returned 200 but wrote planner_model=null to settings/<uid>.json via
model_copy (which skips validation); every subsequent read raised
SettingsStoreError -> 500 on GET/PUT settings, config status, and
create_run, and the file could only be repaired out-of-band.
Route fix (api/app.py): explicit null fields are treated as absent
(partial-merge semantics) before the merge. Store fix (auth/settings.py):
the merged payload is validated BEFORE it touches disk (UserSettings.
model_validate) so a null can never be persisted at all.
Tests: route regression (null PUT -> 200 + defaults preserved + later
reads/writes/runs healthy), store regression (null-field merge refused,
on-disk payload untouched). Hygiene: dead KeyStoreDisabledError import
removed from mock-client.ts (the mock never throws it; only the live
binding does) — frontend lint is now 0 warnings.
Gates: backend 264 passed + 3 skipped, mypy strict clean, ruff clean;
frontend 131 passed, tsc clean, build green, drift gate green, eslint 0.
Regenerated frontend/openapi.json from the P2 backend (uv export via
researchx.api.app) and src/lib/api/generated/schema.d.ts from the
snapshot (G-005). New surface: GET/PUT /api/config/settings,
GET/PUT /api/config/settings/model-key, and the reworked
GET /api/config ConfigStatus (resolved_tier, key_store, key_status,
cost_counters). check:api-drift green.
---ci---
phase: 2
milestone: v0.3
status: execute
persona: backend-engineer
task: T-2.7
requirements:
covered: [REQ-74]
partial: [REQ-75, REQ-85]
---/ci---
researchx/api/main.py — the P-3 backend closure: 'uvicorn
researchx.api.main:app' serves the LIVE wiring (EnvFileSecretsProvider
over the runtime env file — /etc/researchx/env.secrets container
convention, .ciagent/.env.secrets repo-local, RESEARCHX_SECRETS_FILE
override; data root from RESEARCHX_DATA_DIR via paths.data_root();
ModelAdapterFactory with the KeyStore + SettingsStore; search via
resolve_search_adapter — Tavily keyed, documented keyless DuckDuckGo
fallback per G-008).
auth_enabled=True and cookie_secure=True (E-4) are HARDWIRED as
build_app() arguments — no parameters, no env vars, never False on
any serving path (REQ-85/C3-16). create_app now constructs the
fallback fake model ONLY when neither a factory nor a fixed adapter
is wired — a factory-wired app (every production build) holds no
FakeModelAdapter at all; config_status + create_run are fully
factory-driven.
Tests (test_main_entrypoint.py, 8 new): factory resolves real
OllamaModelAdapter never the fake; auth hardwired (401 on all
business routes, /healthz public); cookie Secure hardwired; tier
resolution end to end through the runtime env (server tier with
server key, keyless honest without keys, user tier after key PUT
with encrypted-at-rest file under the DATA ROOT); storage_dir = data
root string; no auth-disable surface in the module (build_app has no
auth_enabled/cookie_secure params, source has no RESEARCHX_AUTH, no
auth_enabled=False). Cookie forwarding in tests is explicit because
the Secure cookie does not replay over plain-http ASGI transport.
Gates: 254 passed + 3 skipped, ruff, mypy.