# SDD Tasks: Service Storage Harness (qBittorrent widgets + MediaIndex migration)

**Change:** `service-storage-harness`
**Phase:** tasks
**Date:** 2026-07-09

## Review Workload Forecast

| Field | Value |
|-------|-------|
| Estimated changed lines | ~1,180–1,380 (sum of four implementation slices) |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | PR 1: Harness + QbittorrentSampleStore + client + integration → PR 2: Widget adapter + LineSeriesChart extract + 3 FE widgets → PR 3: MediaIndex migration → PR 4: Cascade-delete wiring |
| Delivery strategy | auto-chain |
| Chain strategy | stacked-to-main |

```text
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: High
```

> **Slice ordering rationale:** Slices 1–2 prove the harness and deliver all qBittorrent functionality independently of the MediaIndex migration. Slice 3 migrates the load-bearing MediaIndex feature. Slice 4 wires cascade-delete end-to-end. This ordering ensures the two biggest risks (new abstraction × load-bearing refactor) never fire in the same slice — the harness shape is settled in code before MediaIndex touches it.

---

## Slice 1: ServiceDataHarness + QbittorrentSampleStore + QbittorrentClient + integration registration

**Goal:** Build the lifecycle-only storage harness, prove it with the qBittorrent speed-sample store, add the qBittorrent HTTP client and integration registration. No widgets or frontend yet — this slice is backend-only and proves the harness contract with a real consumer.

- [x] **1.1 Create `ServiceDataHarness` lifecycle module**
  - Files: `backend/src/media_library_viewer_api/services/service_data.py` (NEW)
  - Lines: ~100
  - Dependencies: none
  - Details: `StorageConcern` dataclass (`concern_key`, `db_filename`, `migrations: list[str]`, `tables: list[str]`, `service_id_column="service_id"`). `ServiceDataHarness` class with `register(concern)`, `db_path(concern_key)`, `connect(concern_key)` (WAL + busy_timeout 30s), `run_migrations()` (per-concern, per-statement try/except for "duplicate column name"), `cascade_delete(service_id)` (iterate concerns, PRAGMA-check column exists, DELETE WHERE col = ?). Module singleton `get_service_data_harness()` that lazy-inits with `base_dir = BACKEND_CACHE_DIR or ".cache/media_library_viewer"` and calls `run_migrations()`. NO generic value table, NO generic CRUD.

- [x] **1.2 Create `QbittorrentSampleStore` with concern registration**
  - Files: `backend/src/media_library_viewer_api/services/qbittorrent_store.py` (NEW)
  - Lines: ~80
  - Dependencies: 1.1
  - Details: Define `QBITTORRENT_CONCERN` (`db_filename="qbittorrent.db"`, migration creates `qbittorrent_speed_samples(service_id, ts, dl_speed, up_speed)` + index on `(service_id, ts)`, `tables=["qbittorrent_speed_samples"]`). `MAX_SAMPLES = 120`. `QbittorrentSampleStore` class: `append(service_id, ts, dl_speed, up_speed)` (INSERT + prune keeping most recent MAX_SAMPLES for this service_id), `window(service_id, since_ts=None)` (SELECT ordered ASC). Register `QBITTORRENT_CONCERN` with harness on import.

- [x] **1.3 Create `QbittorrentClient` HTTP client**
  - Files: `backend/src/media_library_viewer_api/clients/qbittorrent.py` (NEW)
  - Lines: ~90
  - Dependencies: none
  - Details: `QbittorrentClient(base_url, username, password, timeout=10)` modeled on `JellyfinClient`'s session pattern. `_login()` POSTs to `/auth/login` with `Referer` header, expects `"Ok."` response, stores SID cookie. `_get(path, **params)` auto-logins on first call, re-logins on 403. `maindata()` calls `/sync/maindata` returning `{server_state: {...}, torrents: {hash: {...}}}`. Base URL normalization: append `/api/v2` if not present. Timeout + `requests.RequestException` handling consistent with existing clients.

- [x] **1.4 Create qBittorrent integration definition**
  - Files: `backend/src/media_library_viewer_api/integrations/qbittorrent.py` (NEW)
  - Lines: ~55
  - Dependencies: 1.1 (for `ServiceConfigBase`, `WidgetConfigBase`, `SecretField`, `ServiceDefinition`, `widget_kind` from `base.py`)
  - Details: `QbittorrentConfig(ServiceConfigBase)` with `base_url: ServiceBaseUrl`, `timeout_seconds: int = 10`. `QbittorrentWidgetConfig(WidgetConfigBase)` empty (all three widget kinds derive from service connection). `DEFINITION = ServiceDefinition(service_type="qbittorrent", ...)` with `secret_fields=[username (required), password (required)]` and three `widget_kinds`: `totals` (30s refresh), `active` (15s), `speed` (5s). Model on `integrations/prometheus.py`.

- [x] **1.5 Register qBittorrent in backend integration registry**
  - Files: `backend/src/media_library_viewer_api/integrations/registry.py` (MODIFY)
  - Lines: ~5
  - Dependencies: 1.4
  - Details: Import `DEFINITION as QBITTORRENT` from `integrations/qbittorrent.py`; add `SERVICE_DEFINITIONS["qbittorrent"] = QBITTORRENT`. Verify `list_service_types()` now includes `qbittorrent`.

- [x] **1.6 Initialize harness in application lifespan**
  - Files: `backend/src/media_library_viewer_api/main.py` (MODIFY, lifespan function ~line 41)
  - Lines: ~5
  - Dependencies: 1.1, 1.2
  - Details: In `lifespan()` after `get_settings_store().ensure_defaults()`, add a try/except block calling `get_service_data_harness()` (triggers lazy init + migration run). Log on failure (same pattern as the settings seed block at lines 51–54). Import is local inside lifespan to avoid circular import risk.

- [x] **1.7 Create backend tests for harness + store**
  - Files: `backend/tests/test_service_data.py` (NEW)
  - Lines: ~110
  - Dependencies: 1.1, 1.2
  - Details: Test `ServiceDataHarness`: register concern → `run_migrations()` creates table; `cascade_delete(service_id)` removes rows by service_id but leaves other services' rows; migration idempotency (re-run doesn't crash). Test `QbittorrentSampleStore`: `append` + prune (append 130 samples, assert only 120 remain), `window` returns ordered samples, two services don't cross-contaminate. Use `tmp_path` for base_dir isolation.

- [x] **1.8 Create backend tests for qBittorrent client**
  - Files: `backend/tests/test_qbittorrent_client.py` (NEW)
  - Lines: ~85
  - Dependencies: 1.3
  - Details: Test login flow (mock `requests.Session.post` → `"Ok."` + cookie), cookie reuse on subsequent calls, 403 → re-login flow (first GET returns 403, second returns 200 after re-login), `maindata()` parsing, base URL normalization (appends `/api/v2`), login failure (response ≠ "Ok." raises RuntimeError), timeout handling. Use `unittest.mock.patch` on the session.

- [x] **1.9 Verify Slice 1 (backend only)**
  - Run: `cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest tests/test_service_data.py tests/test_qbittorrent_client.py -v`
  - Verify: all new tests pass; existing tests unaffected.

**Slice 1 total:** ~310–390 changed lines. Backend-only; no frontend changes.

---

## Slice 2: Widget source adapter + LineSeriesChart extraction + frontend widgets + registry binding

**Goal:** Wire qBittorrent data into the widget system, extract a shared chart renderer, and build the three frontend widgets. This slice touches `PrometheusChartWidget` (extraction) — its existing tests MUST stay green.

- [x] **2.1 Add `QbittorrentWidgetSource` adapter to `widgets/sources.py`**
  - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (MODIFY)
  - Lines: ~75
  - Dependencies: Slice 1 (1.3, 1.2)
  - Details: Add `QbittorrentWidgetSource` implementing `WidgetSource.fetch(service, widget_kind, config)`. Resolves client inline from `ServiceRecord` config+secrets (like `PrometheusWidgetSource`). Three branches:
    - `totals`: `len(torrents)` + `by_state` dict breakdown. Returns `{total, by_state}`.
    - `active`: filter `state in {"downloading","uploading"}`. Returns `{torrents: [{name, state, size, progress, dl_speed, up_speed}]}`.
    - `speed`: append sample to `QbittorrentSampleStore(service.id, ts, dl, up)`, read `window(service.id)`, return `{series: [{label:"download", points:[{t: ts*1000, v: dl}]}, {label:"upload", ...}]}` (timestamps ×1000 for JS epoch).
    All branches wrap in try/except → `{"error": ...}`. Add `"qbittorrent": QbittorrentWidgetSource()` to `SERVICE_ADAPTERS`.

- [x] **2.2 Add backend tests for qBittorrent widget adapter**
  - Files: `backend/tests/test_widgets.py` (EXTEND)
  - Lines: ~90
  - Dependencies: 2.1
  - Details: Test `QbittorrentWidgetSource` with mocked `QbittorrentClient.maindata()`: totals counts all torrents + breaks down by state; active filters to DL/UL only (assert queued/stalled excluded); speed appends sample and returns `{series}` shape with two labeled series; missing service → `{"error": ...}`; timeout → `{"error": ...}`. Use `tmp_path` harness override for the store.

- [x] **2.3 Extract shared `LineSeriesChart` component from `PrometheusChartWidget`**
  - Files: `frontend/src/components/LineSeriesChart.tsx` (NEW), `frontend/src/widgets/PrometheusChartWidget.tsx` (MODIFY)
  - Lines: ~75 (new file) + ~20 (PrometheusChartWidget thin wrapper)
  - Dependencies: none (refactor of existing code)
  - Details: **RISK ITEM (a).** Extract `mergeSeries`, `formatTime`, `CHART_COLORS`, `ChartSeries`/`SeriesPoint` types, and the `<ResponsiveContainer>+<LineChart>+<XAxis>+<YAxis>+<CartesianGrid>+<Tooltip>+<Line>` JSX from `PrometheusChartWidget.tsx` into `components/LineSeriesChart.tsx`. Props: `{ series: ChartSeries[], height?: number }`. `PrometheusChartWidget` becomes: `useWidgetData` → `<LineSeriesChart series={data.data.series} />`. Export `ChartSeries` type from `LineSeriesChart` for reuse. **PrometheusChartWidget's existing tests MUST stay green — the extraction is mechanical (move + import), not a rewrite.**

- [x] **2.4 Create `QbittorrentTotalsWidget` component**
  - Files: `frontend/src/widgets/QbittorrentTotalsWidget.tsx` (NEW)
  - Lines: ~60
  - Dependencies: 2.3
  - Details: Renders `{total, by_state}` from `useWidgetData`. Uses `SectionCard` + prominent total count + state breakdown badges (e.g. `downloading: 3`, `uploading: 1`, `paused: 5`). Loading skeleton + error alert + empty state. Model on `BackupsWidget`/`MetricCard` patterns.

- [x] **2.5 Create `QbittorrentActiveTorrentsWidget` component**
  - Files: `frontend/src/widgets/QbittorrentActiveTorrentsWidget.tsx` (NEW)
  - Lines: ~80
  - Dependencies: 2.3
  - Details: Renders `{torrents: [...]}` from `useWidgetData`. Uses `SectionCard` + compact table/list (manual `Table` rows, NOT DataTable — keep it simple for ≤10 active torrents). Columns: name, state badge, progress bar, dl/up speed. Scrollable container for overflow. Loading + error + empty ("No active torrents") states. Reuses `humanSize`-style formatting for speeds (bytes/s → MB/s).

- [x] **2.6 Create `QbittorrentSpeedWidget` component**
  - Files: `frontend/src/widgets/QbittorrentSpeedWidget.tsx` (NEW)
  - Lines: ~40
  - Dependencies: 2.3
  - Details: Renders `{series}` from `useWidgetData` — IDENTICAL pattern to `PrometheusChartWidget`. Thin wrapper: `useWidgetData` → `<LineSeriesChart series={data.data.series} height={220} />`. Loading skeleton + error alert + empty state ("No speed data yet"). Refresh interval from registry binding (5s).

- [x] **2.7 Add qBittorrent binding to frontend registry**
  - Files: `frontend/src/integrations/registry.ts` (MODIFY)
  - Lines: ~40
  - Dependencies: 2.4, 2.5, 2.6
  - Details: Import the three new widget components. Add `qbittorrent` entry to `SERVICE_REGISTRY` with three widget kinds: `totals` (30s, `QbittorrentTotalsWidget`), `active` (15s, `QbittorrentActiveTorrentsWidget`), `speed` (5s, `QbittorrentSpeedWidget`). All with empty config schemas (`{type:"object", properties:{}, required:[]}`). Update barrel `widgets/index.ts` with the three new exports.

- [x] **2.8 Create frontend tests for new widgets + LineSeriesChart**
  - Files: `frontend/src/widgets/__tests__/QbittorrentTotalsWidget.test.tsx` (NEW), `frontend/src/widgets/__tests__/QbittorrentActiveTorrentsWidget.test.tsx` (NEW), `frontend/src/widgets/__tests__/QbittorrentSpeedWidget.test.tsx` (NEW), `frontend/src/components/__tests__/LineSeriesChart.test.tsx` (NEW)
  - Lines: ~110 (4 files × ~25-30 lines)
  - Dependencies: 2.3, 2.4, 2.5, 2.6
  - Details: Each widget test: loading skeleton (`data-slot="skeleton"`), error alert (destructive variant), rendered data case. `LineSeriesChart.test.tsx`: renders lines from series data, empty state (no series). Model on existing `PrometheusChartWidget.test.tsx` mock pattern (`vi.mocked(useWidgetData).mockReturnValue(...)`). **Verify `PrometheusChartWidget.test.tsx` still passes after extraction.**

- [x] **2.9 Verify Slice 2 (full stack)**
  - Run: `cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest tests/test_widgets.py -v`
  - Run: `cd frontend && npm run lint && npm run build && npx vitest run src/widgets/__tests__/Qbittorrent src/widgets/__tests__/PrometheusChart src/components/__tests__/LineSeriesChart`
  - Verify: PrometheusChartWidget tests GREEN (extraction not a regression); qBit widget tests GREEN; build + lint GREEN.

**Slice 2 total:** ~350–400 changed lines.

---

## Slice 3: MediaIndex migration onto harness (load-bearing refactor)

**Goal:** Register MediaIndex as a harness concern, add `service_id` scoping to the schema, fix the latent global-clear bug in `replace_items`, and thread `service_id` through query/build/worker. All existing MediaIndex tests MUST pass unchanged.

- [x] **3.1 Define `MEDIA_INDEX_CONCERN` and register with harness**
  - Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY — add concern constant near top), `backend/src/media_library_viewer_api/services/service_data.py` (MODIFY — register concern in `get_service_data_harness`)
  - Lines: ~25
  - Dependencies: Slice 1 (1.1)
  - Details: `MEDIA_INDEX_CONCERN = StorageConcern(concern_key="media_index", db_filename="media_index.sqlite", migrations=["ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''"], tables=["media_items"])`. Register in harness singleton alongside `QBITTORRENT_CONCERN`. The `db_filename` MUST match the existing `DEFAULT_INDEX_PATH` leaf name (`media_index.sqlite`). The harness `base_dir` (`.cache/media_library_viewer`) is the same parent — no file move.

- [x] **3.2 Add `service_id` column to `init_schema` CREATE TABLE**
  - Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY, `init_schema` ~line 107)
  - Lines: ~3
  - Dependencies: 3.1
  - Details: Add `service_id TEXT NOT NULL DEFAULT ''` as the last column in the `CREATE TABLE IF NOT EXISTS media_items` statement. New installs get the column from `init_schema`; existing DBs get it from the harness ALTER migration. Both paths converge on the same schema.

- [x] **3.3 Scope `replace_items` by `service_id` (FIXES latent global-clear bug)**
  - Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY, `replace_items` ~line 166)
  - Lines: ~20
  - Dependencies: 3.2
  - Details: **RISK ITEM (b).** Add `service_id: str = ""` parameter. Add `"service_id"` to the columns list. Change `DELETE FROM media_items` → `DELETE FROM media_items WHERE service_id = ?` with `(service_id,)`. Change the `executemany` rows to prepend `service_id` to each row's values. This FIXES the latent bug where building for one Jellyfin wipes another's rows. Existing tests call `replace_items(rows)` with no `service_id` → defaults to `""` → deletes `WHERE service_id = ''` → backward-compatible (existing test data has `service_id=''` from backfill).

- [x] **3.4 Scope `query` by `service_id`**
  - Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY, `query` ~line 279)
  - Lines: ~10
  - Dependencies: 3.2
  - Details: Add `service_id: str = ""` parameter. After existing WHERE clause building, add: `if service_id: where.append("service_id = ?"); params.append(service_id)`. When `service_id=""` (empty), skip the filter → shows all rows (backward-compatible). This means existing tests (which pass no `service_id`) see all rows unchanged.

- [x] **3.5 Thread `service_id` through `build_media_index` → `replace_items`**
  - Files: `backend/src/media_library_viewer_api/services/media_index_impl.py` (MODIFY, `build_media_index` ~line 331)
  - Lines: ~5
  - Dependencies: 3.3
  - Details: Add `service_id: str = ""` parameter to `build_media_index(...)`. Change the `count = index.replace_items(normalized_rows)` call (~line 458) to `count = index.replace_items(normalized_rows, service_id=service_id)`.

- [x] **3.6 Pass `service_id` from worker to `build_media_index`**
  - Files: `backend/src/media_library_viewer_api/workers/media_index_worker.py` (MODIFY, `run_build` ~line 144, `build_media_index` call ~line 168)
  - Lines: ~3
  - Dependencies: 3.5
  - Details: The worker's `run_build(final_index_path, staging_index_path, service_id="")` ALREADY receives `service_id` from argparse (`--service-id`). Change the `build_media_index(...)` call (~line 168) to pass `service_id=service_id`.

- [x] **3.7 Add `jellyfin_service_id` to `query_media` and pass to `query`**
  - Files: `backend/src/media_library_viewer_api/routers/media.py` (MODIFY, `query_media` ~line 276)
  - Lines: ~5
  - Dependencies: 3.4
  - Details: `query_media` does NOT currently have a `jellyfin_service_id` param (unlike `post_build_index` which does). Add `jellyfin_service_id: str | None = None` to the signature (after `offset`). Pass `service_id=jellyfin_service_id or ""` to the `index.query(...)` call (~line 310). Existing callers that don't send the param → `None` → `""` → all rows (backward-compatible).

- [x] **3.8 Add backend tests for scoped MediaIndex operations**
  - Files: `backend/tests/test_media_index.py` (EXTEND)
  - Lines: ~45
  - Dependencies: 3.3, 3.4
  - Details: Add tests for the NEW scoped behavior: `replace_items(rows, service_id="svc-a")` then `replace_items(other_rows, service_id="svc-b")` — assert svc-a rows survive svc-b's replace (proves the bug fix). `query(service_id="svc-a")` returns only svc-a rows. `query(service_id="")` returns all rows. `query()` (no arg) returns all rows. **Existing MediaIndex tests MUST pass unchanged** — verify they don't break by running the full file.

- [x] **3.9 Verify Slice 3 (backend, load-bearing)**
  - Run: `cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest tests/test_media_index.py -v`
  - Run: `cd backend && PYTHONPATH=src python -m pytest -q` (full suite — no regressions)
  - Verify: ALL existing MediaIndex tests pass unchanged; new scoped tests pass; full suite green.

**Slice 3 total:** ~200–260 changed lines. Backend-only; Media page UX unchanged.

---

## Slice 4: Cascade-delete wiring end-to-end + integration test

**Goal:** Hook `ServiceDataHarness.cascade_delete` into `SettingsStore.delete_service` so removing a service wipes its owned data across all harness-managed tables. End-to-end integration test.

- [x] **4.1 Wire harness cascade-delete into `delete_service`**
  - Files: `backend/src/media_library_viewer_api/services/settings_store.py` (MODIFY, `delete_service` ~line 1742)
  - Lines: ~10
  - Dependencies: Slice 1 (1.1), Slice 3 (3.1)
  - Details: After the existing `DELETE FROM services WHERE id = ?` (line 1754), add a try/except block: `from media_library_viewer_api.services.service_data import get_service_data_harness; get_service_data_harness().cascade_delete(service_id)`. Log on failure (`logger.exception(...)`) — cascade is best-effort (service row is already deleted; data cleanup must not block deletion). Local import to avoid circular dependency.

- [x] **4.2 Add integration test for cascade-delete**
  - Files: `backend/tests/test_services.py` (EXTEND)
  - Lines: ~45
  - Dependencies: 4.1
  - Details: Test end-to-end cascade: (1) create a qBittorrent service, append speed samples for it, delete the service → assert samples gone from `qbittorrent.db`. (2) Create two Jellyfin services, build media for both with distinct `service_id`s, delete one → assert only that service's media rows removed, other survives. Use `tmp_path` for both settings DB and harness base_dir. Verify `delete_service` doesn't raise even if harness fails (best-effort guard).

- [x] **4.3 Verify Slice 4 (full suite)**
  - Run: `cd backend && PYTHONPATH=src ruff check src tests && PYTHONPATH=src python -m pytest -q`
  - Run: `cd frontend && npm run lint && npm run build`
  - Verify: full backend suite green (293+ tests + new); frontend unaffected (no FE changes in this slice).

**Slice 4 total:** ~80–110 changed lines. Backend-only.

---

## Integration and acceptance verification

- [x] **5.1 Full backend test run**
  - Run: `cd backend && PYTHONPATH=src python -m pytest -q`
  - Verify: all existing tests pass + all new tests (harness, store, client, widget adapter, scoped MediaIndex, cascade-delete) pass.

- [x] **5.2 Full frontend build + lint**
  - Run: `cd frontend && npm run lint && npm run build`
  - Verify: no TypeScript errors; no new lint failures; PrometheusChartWidget tests still green after LineSeriesChart extraction.

- [x] **5.3 Verify qBittorrent end-to-end (manual or integration)**
  - Verify: a qBittorrent service can be registered; three widget kinds are bindable; totals shows item count; active shows DL/UL torrents; speed shows a live recharts line chart.

- [x] **5.4 Verify MediaIndex backward-compat**
  - Verify: existing Media page works identically (no `service_id` → sees all rows); building for a specific Jellyfin service scopes correctly.

- [x] **5.5 Verify cascade-delete**
  - Verify: deleting a service removes its qBit samples + media items; deleting one of multiple Jellyfin services removes only its rows.

---

## Total estimate

| Slice | Changed lines | Risk |
|-------|---------------|------|
| Slice 1: Harness + store + client + integration | ~310–390 | Low (new code, no existing behavior touched) |
| Slice 2: Widget adapter + LineSeriesChart + FE widgets | ~350–400 | Medium (touches PrometheusChartWidget) |
| Slice 3: MediaIndex migration | ~200–260 | **High** (load-bearing; must fix replace_items bug + keep tests green) |
| Slice 4: Cascade-delete wiring | ~80–110 | Low (small, well-isolated) |
| Integration tests | ~45 | Low |
| **Total** | **~985–1,205** | |

---

## Guard lines

```text
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: High
```

---

## Risk callouts (for reviewer + apply agents)

| # | Risk | Slice | Mitigation |
|---|------|-------|------------|
| **R1** | `LineSeriesChart` extraction breaks `PrometheusChartWidget` tests | S2 (2.3, 2.8) | Extraction is mechanical (move JSX + helpers, pass series as prop). S2 exit gate explicitly verifies `PrometheusChartWidget.test.tsx` still passes. |
| **R2** | MediaIndex migration breaks existing tests or introduces a regression | S3 (3.3, 3.8) | `service_id` defaults to `""`; empty-string queries skip the filter → all rows visible. `replace_items` bug fix changes `DELETE FROM media_items` → `DELETE WHERE service_id = ?` with `""` default → existing tests (which use `""`) are unaffected. S3 exit gate runs full backend suite. |
| **R3** | ALTER TABLE fails on fresh installs where `init_schema` already created the column | S3 (3.1) | `run_migrations` catches "duplicate column name" per-statement (designed in Slice 1). |
| **R4** | Harness lazy-init hides migration failures on startup | S1 (1.6) | `run_migrations` runs on first access in lifespan; failures raise (only "duplicate column name" is swallowed). |
| **R5** | Circular import between `settings_store` and `service_data` | S4 (4.1) | Cascade-delete uses local import inside `delete_service` method, not module-level. |
