# Verify Report — service-storage-harness

> Phase: **verify** · Change: `service-storage-harness` · Repo: `/home/user/manage`
> FRESH-CONTEXT adversarial read-only verification of the change against
> `proposal.md`, `spec.md`, `design.md`, and `tasks.md`. **No source edits.**
> This verify report is the only file written.

**Head commit verified:** `c9404f0` (`spec(service-storage-harness): add spec`).
Four implementation slices are committed underneath it:

- `e7bd0af` slice 1 — harness + qbit store + client + integration
- `1fb12b8` slice 2 — qbit widgets + LineSeriesChart extract  *(brief cited `8b0e7ea`,
  an earlier amend state; the actual landed commit is `1fb12b8`. Content matches
  the spec/design/tasks; informational, not a defect.)*
- `c87f398` slice 3 — migrate MediaIndex onto harness (scoped replace_items, +service_id)
- `75c949a` slice 4 — cascade-delete wiring + integration test

---

## 0. Executive summary / verdict

**VERDICT: PASS — implementation complete and green; archive BLOCKED on a
task-hygiene / missing-`apply-progress` issue (reconcilable without code).**

Every functional requirement **SS-101 … SS-128** was checked against source and
**passes**. The `ServiceDataHarness` is genuinely lifecycle-only (provisioning,
idempotent migrations, `service_id` cascade-delete) with **no** generic value
table or CRUD. The qBittorrent stack (store + cookie-reusing client + 3-branch
adapter + 3 frontend widgets) is wired consistently in **both** registries with
matching kinds/names/refresh intervals. The **load-bearing** MediaIndex migration
(SS-119..SS-124) is correct: `replace_items` is scoped `WHERE service_id = ?`
(fixing the latent global-clear bug), with a real regression test proving
service A's rows survive service B's rebuild; existing MediaIndex tests pass
unchanged via the `service_id=""` default. The `LineSeriesChart` extraction is
non-regressive (PrometheusChartWidget tests stay green; it becomes a thin
wrapper). Cascade-delete is wired into `delete_service` (best-effort try/except)
and initialized in `lifespan`, with an end-to-end integration test covering
**both** concerns with multi-instance preservation. All four gates are green:
backend `pytest` (**322 passed**), `ruff` (**clean**), frontend
`npm run build` (**exit 0**), `npm run lint` (**0 errors**).

Findings:

- **[CRITICAL — archive blocker, NOT a code defect]** **All 30
  implementation/verification task checkboxes** remain unchecked in `tasks.md`
  (§1.1–1.9, §2.1–2.9, §3.1–3.9, §4.1–4.3, §5.1–5.5), and **`apply-progress.md`
  does not exist** to reconcile them. The underlying work *is* done and verified
  complete against source; the blocker is that the task tracker was never updated
  and no apply-progress artifact was produced. Reconciliation = tick the boxes +
  write `apply-progress.md` (no code change). See §4.
- **[WARNING — review workload]** Slice 2 (`1fb12b8`) lands **~644 non-test
  source lines** (widget adapter + `LineSeriesChart` extraction + 3 FE widgets),
  above the 400-line budget and above the slice-2 forecast of "~350–400". The
  boundary is exactly the qBit-widget feature (no unrelated files, no scope
  creep), and the forecast itself rated slice 2 "Medium"; but no `size:exception`
  was recorded. Recommend recording the slice-2 actual in the archive summary.
  **Non-blocking.** See §6.
- **[INFO]** `LineSeriesChart.test.tsx` is smoke-only (asserts `container.firstChild`
  is not null; does not assert recharts `<Line>` SVGs rendered). Same weak-render
  pattern noted in the Change A report. The PrometheusChartWidget rendered case
  is title-only. Non-blocking coverage note.
- **[INFO]** `QbittorrentSampleStore.append` prunes via
  `ts NOT IN (SELECT ts … LIMIT 120)`; in the degenerate case of two samples
  sharing an identical `ts`, the keep-set could exceed `MAX_SAMPLES`. At a 5 s
  poll the probability is effectively nil; the per-service cap invariant holds in
  all realistic operation. Non-blocking.
- **[INFO]** Stale generated `.pi-map.md` files predate these new files
  (`service_data.py`, `qbittorrent_store.py`, `clients/qbittorrent.py`,
  `Qbittorrent*.tsx`, `LineSeriesChart.tsx`, etc.). Not deliverable source;
  regenerate via `project_map_patch`/`project_map_validate`.

---

## 1. Structured status & actionContext findings

The native `gentle-pi.sdd-status` reports `changeName: null` /
`blockedReasons: ["Change selection is ambiguous: mobile-responsive-parity,
service-storage-harness, services-as-hub-ia"]` because the engine auto-detected
three active changes. This verify task was **explicitly assigned**
`service-storage-harness`; the ambiguity is a parent-resolution artifact and does
not block this phase.

- `artifactStore: openspec`; change root
  `openspec/changes/service-storage-harness/`.
- Artifacts present: `proposal.md`, `design.md`, `spec.md`, `tasks.md`.
- **`apply-progress.md`: MISSING** (confirmed: directory contains only the four
  planning docs). This is the root cause of the §4 archive blocker.
- `actionContext`: `mode: repo-local`, `workspaceRoot: /home/user/manage`,
  `allowedEditRoots: ["/home/user/manage"]`, `warnings: []`. Implementation
  ownership and all target files are provably inside the authoritative workspace. ✓

## 2. Gate results (actual output, run at `c9404f0`)

| Gate | Command | Result | Evidence |
|------|---------|--------|----------|
| Backend tests | `cd backend && PYTHONPATH=src python3 -m pytest -q` | **PASS** | **322 passed, 2 warnings** in 39.36s. Includes new `test_service_data.py` (9), `test_qbittorrent_client.py` (12), qBit adapter tests (6) in `test_widgets.py`, scoped MediaIndex regression (`test_replace_scoped_by_service_id_preserves_other_services`), and cascade integration (`test_cascade_delete_removes_harness_data_across_concerns`). |
| Backend lint | `cd backend && PYTHONPATH=src python3 -m ruff check src tests` | **PASS** | `All checks passed!` |
| Frontend build | `cd frontend && npm run build` (`tsc -b` + `vite build`) | **PASS** exit 0 | `✓ built in 1.22s`; 2543 modules transformed. Non-fatal `>500 kB` chunk-size warning (pre-existing, orthogonal). |
| Frontend lint | `cd frontend && npm run lint` (`eslint .`) | **PASS** exit 0 | `0 errors, 1 warning`. The warning is `react-hooks/exhaustive-deps` in `WidgetConfigDialog.tsx:370` — **pre-existing, untouched by this change** (no slice modified that file). |
| Extraction non-regression | `cd frontend && npx vitest run src/widgets/__tests__/PrometheusChartWidget.test.tsx` | **PASS** | **4 passed** (SS-118: LineSeriesChart extraction did not regress PrometheusChartWidget). |
| New FE widget + chart tests | `npx vitest run src/widgets/__tests__/ src/components/__tests__/LineSeriesChart.test.tsx` | **PASS** | **8 files, 31 tests passed** (3 qBit widgets + PrometheusChart/Gauge/Mean + LineSeriesChart). |

## 3. Spec coverage (SS-101 … SS-128)

| SS | Requirement | Verdict | Evidence |
|----|-------------|---------|----------|
| SS-101 | Harness is lifecycle-only | **PASS** | `service_data.py::ServiceDataHarness` exposes only `register`/`db_path`/`connect`/`run_migrations`/`cascade_delete`. No generic value table, no generic CRUD — each integration keeps bespoke stores (`append/window`, `replace_items/query`). Module docstring states the contract explicitly. |
| SS-102 | Concern registration dataclass | **PASS** | `StorageConcern` frozen dataclass carries `concern_key`, `db_filename`, `migrations: list[str]`, `tables: list[str]`, `service_id_column="service_id"`. `register()` stores by `concern_key`. |
| SS-103 | Idempotent migrations | **PASS** | `run_migrations` splits each migration on `;` and executes per-statement; catches `sqlite3.OperationalError` for "duplicate column name" **and** "no such table", logging at debug and continuing. Test `test_cascade_delete_*` + harness tests re-init without raising. |
| SS-104 | Cascade-delete across concerns | **PASS** | `cascade_delete(service_id)` iterates **every** registered concern, PRAGMA-checks the column exists, `DELETE FROM <table> WHERE <col> = ?`. Both `QBITTORRENT_CONCERN` and `MEDIA_INDEX_CONCERN` are registered in `get_service_data_harness()`, so it covers `qbittorrent_speed_samples` + `media_items`. Skips concern DBs that don't exist (no crash). |
| SS-105 | Sample store schema | **PASS** | `qbittorrent_store.py` migration creates `qbittorrent_speed_samples(service_id, ts, dl_speed, up_speed)` + index `idx_qbit_samples_service_ts ON (service_id, ts)`, in `qbittorrent.db` (per-concern topology via `db_filename`). |
| SS-106 | append/window/prune | **PASS** | `append(service_id, ts, dl, up)` INSERTs then prunes to `MAX_SAMPLES=120` (DELETE `ts NOT IN (top-120 for this service_id)`). `window(service_id, since_ts=None)` SELECTs ordered ASC. Test appends 130 → asserts 120 remain; two services don't cross-contaminate. |
| SS-107 | Store registered as concern | **PASS** | `QBITTORRENT_CONCERN` registered in `get_service_data_harness()` alongside the media concern. |
| SS-108 | Cookie login | **PASS** | `QbittorrentClient._login()` POSTs `/auth/login` (with `Referer` header), expects `"Ok."`, stores SID cookie in the shared `requests.Session`. Credentials resolved from `ServiceRecord.secrets` (Fernet-encrypted at rest) in the adapter. Test `test_login_posts_credentials`. |
| SS-109 | 403 re-login | **PASS** | `_get` retries once after 403: sets `_logged_in=False`, re-`_login()`, re-GETs. Test `test_403_triggers_re_login` asserts `session.get` called twice + `session.post` once. |
| SS-110 | maindata fetch | **PASS** | `maindata()` → `_get("/sync/maindata")` returns the dict (`server_state` + `torrents`). Timeout/connection/non-2xx propagate as exceptions (`raise_for_status`); the adapter catches them (SS-115). Test `test_maindata_returns_full_payload`. |
| SS-111 | Three widget kinds dispatched | **PASS** | `QbittorrentWidgetSource.fetch` resolves `QbittorrentClient` inline from `ServiceRecord` config+secrets, dispatches on `widget_kind ∈ {totals, active, speed}`. Registered in `SERVICE_ADAPTERS["qbittorrent"]`. |
| SS-112 | totals = item count | **PASS** | `totals` → `{"total": len(torrents), "by_state": {...}}`. Count of currently-listed torrents, **not** cumulative bytes. Test `test_qbittorrent_totals_counts_all_torrents`. |
| SS-113 | active = DL/UL filter | **PASS** | `active` keeps only `state in {"downloading","uploading"}` → `{"torrents": [...]}`. Test `test_qbittorrent_active_filters_dl_ul_only` asserts queued/stalled excluded. |
| SS-114 | speed appends + returns series | **PASS** | `speed` reads `dl_info_speed`/`up_info_speed`, `store.append(service.id, ts, dl, up)`, returns `{"series": [{label:"download", points:[...]}, {label:"upload", points:[...]}]}` from `.window()` with `t = ts * 1000` (JS ms). Test `test_qbittorrent_speed_appends_and_returns_series` asserts labels + last download value. |
| SS-115 | Errors degrade gracefully | **PASS** | `fetch` body wrapped in `try/except`: `asyncio.TimeoutError → {"error": "...timed out"}`, generic `Exception → {"error": "...failed: ..."}`. Missing service / missing creds return `{"error":...}` before any network call. Tests cover missing-service, missing-credentials, timeout. Never raises. |
| SS-116 | Three FE widget components + binding | **PASS** | `QbittorrentTotalsWidget`, `QbittorrentActiveTorrentsWidget`, `QbittorrentSpeedWidget` exist under `frontend/src/widgets/`; `registry.ts` binds them to `qbittorrent` with kinds `totals`/`active`/`speed`. |
| SS-117 | Speed widget reuses shared renderer | **PASS** | `QbittorrentSpeedWidget` → `<LineSeriesChart series={series} height={220} />`. No new charting code, no new charting dependency. |
| SS-118 | LineSeriesChart extraction non-regressive | **PASS** | `LineSeriesChart.tsx` owns `mergeSeries`/`formatTime`/`CHART_COLORS`/`ChartSeries`/recharts JSX; `PrometheusChartWidget.tsx` is a thin wrapper (`useWidgetData` → `<LineSeriesChart series={series} />`). `PrometheusChartWidget.test.tsx` → **4 passed**. |
| SS-119 | service_id column added (idempotent) | **PASS** | `MEDIA_INDEX_CONCERN.migrations = ["ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''"]`; `init_schema` CREATE TABLE also includes it. `db_filename="media_index.sqlite"` == `DEFAULT_INDEX_PATH.name`; harness `base_dir` == `.cache/media_library_viewer` → **file location unchanged**. |
| SS-120 | Existing rows backfilled | **PASS** | Column `DEFAULT ''` backfills all pre-existing rows to `service_id=""` (preserving visibility). |
| SS-121 | replace_items scoped (bug fix) | **PASS** | `replace_items(rows, service_id="")` does `DELETE FROM media_items WHERE service_id = ?` (NOT global). **Regression test `test_replace_scoped_by_service_id_preserves_other_services`** inserts svc-a (2 rows), replaces svc-b (1 row), asserts `item_count==3` (svc-a survives), and scoped queries return only each service's rows. Latent global-clear bug confirmed fixed. |
| SS-122 | query backward-compatible | **PASS** | `query(..., service_id="")`: non-empty appends `WHERE service_id = ?`; empty (default) skips the filter → all rows. Existing `test_media_index.py` / `test_api.py` callers pass no `service_id` and run **unchanged** (322 passed). |
| SS-123 | Worker threads service_id | **PASS** | `run_build(..., service_id="")` receives `--service-id` from argparse and passes `service_id=service_id` into `build_media_index(...)`, which calls `index.replace_items(normalized_rows, service_id=service_id)` (line 488). Newly-built rows stamped with the real service_id. |
| SS-124 | MediaIndex registered as concern | **PASS** | `MEDIA_INDEX_CONCERN` (tables=`["media_items"]`) registered in `get_service_data_harness()`, so `media_items` participates in cascade-delete. |
| SS-125 | delete_service triggers cascade | **PASS** | `settings_store.delete_service` (after `DELETE FROM services WHERE id=?`) does `try: get_service_data_harness().cascade_delete(service_id); except Exception: logger.exception(...)` — **best-effort, local import** to avoid circular dependency. A cascade failure does not crash deletion. |
| SS-126 | End-to-end cascade across both concerns | **PASS** | `test_cascade_delete_removes_harness_data_across_concerns`: qBit A+B samples appended, delete A → A gone, B survives; media items A+B built with distinct service_ids, delete A → A rows gone (`total_a_after==0`), B survives (`total_b_after==1`). Both concerns + multi-instance preservation proven. |
| SS-127 | Backend tests + lint green | **PASS** | `pytest` → **322 passed**; `ruff check src tests` → `All checks passed!`. New tests present for harness lifecycle, qBit store, qBit client (login/403/cookie-reuse/maindata), qBit adapter (3 branches + errors), scoped MediaIndex regression, cascade-delete integration. |
| SS-128 | Frontend build + lint green | **PASS** | `npm run build` exit 0; `npm run lint` 0 errors (1 pre-existing warning). New widget tests cover **loading/error/rendered** states (Totals includes an explicit `isLoading:true` skeleton case — improving on Change A's SC-125 gap). PrometheusChartWidget tests green (SS-118). |

**Functional spec coverage: 28/28 fully PASS.**

---

## 4. Task completion status — ⚠ archive blocker (reconcilable)

`tasks.md` checkbox state (via `grep -nE '^\s*- \[' tasks.md`):

- **Slice 1 (§1.1–1.9): all 9 `[ ]` — UNCHECKED**
- **Slice 2 (§2.1–2.9): all 9 `[ ]` — UNCHECKED**
- **Slice 3 (§3.1–3.9): all 9 `[ ]` — UNCHECKED**
- **Slice 4 (§4.1–4.3): all 3 `[ ]` — UNCHECKED**
- **Integration (§5.1–5.5): all 5 `[ ]` — UNCHECKED**

Total: **0 checked, 30 unchecked**. **`apply-progress.md` does not exist.**

**This is a CRITICAL completeness issue for the archive gate per the verify
contract.** However — as with Change A — **all 30 items are verifiably DONE
against source**. Selected reconciliation (full mapping available on request):

| Unchecked task | Actual state (verified) |
|----------------|-------------------------|
| 1.1 `ServiceDataHarness` module | **done** (`service_data.py` exists; lifecycle-only) |
| 1.2 `QbittorrentSampleStore` | **done** (`qbittorrent_store.py`; `QBITTORRENT_CONCERN` registered) |
| 1.3 `QbittorrentClient` | **done** (`clients/qbittorrent.py`; login/403/maindata) |
| 1.4/1.5 integration def + registry | **done** (`integrations/qbittorrent.py` + `registry.py` entry) |
| 1.6 harness init in lifespan | **done** (`main.py:56-60` try/except) |
| 1.7/1.8 backend tests | **done** (`test_service_data.py`=9, `test_qbittorrent_client.py`=12) |
| 2.1 adapter | **done** (`QbittorrentWidgetSource` + `SERVICE_ADAPTERS`) |
| 2.3 LineSeriesChart extract | **done** (`components/LineSeriesChart.tsx`; PrometheusChartWidget thin wrapper) |
| 2.4–2.6/2.7 3 FE widgets + binding | **done** (`QbittorrentTotals/Active/SpeedWidget` + registry.ts) |
| 2.8 FE tests | **done** (3 widget tests + LineSeriesChart test) |
| 3.1–3.7 MediaIndex migration | **done** (concern + column + scoped replace_items + scoped query + worker/router threading) |
| 3.8 scoped MediaIndex tests | **done** (`test_replace_scoped_by_service_id_preserves_other_services`) |
| 4.1/4.2 cascade wiring + integration test | **done** (`delete_service` cascade + `test_cascade_delete_removes_harness_data_across_concerns`) |
| 5.1–5.5 full-suite gates | **done** (322 passed / ruff clean / build+lint exit 0 / extraction non-regression) |

The unchecked boxes are **stale** (work performed, tracker not updated), and **no
`apply-progress.md` exists** to serve as the stale-checkbox reconciliation record
the contract permits. Resolution is a **documentation-only** step: tick
§1.1–1.9, §2.1–2.9, §3.1–3.9, §4.1–4.3, §5.1–5.5, and author `apply-progress.md`
documenting the four landed slices. **No code change is required.**

> Per the verify contract, an unchecked implementation-task line is an archive
> blocker until reconciled. Because the implementation is verified complete, this
> blocks **archive** but does **not** block `sdd-sync` of the green code.

## 5. TDD compliance & assertion-quality assessment

Strict-TDD was **not** declared active for this change in `openspec/config.yaml` /
parent prompt / (absent) `apply-progress.md`, so the formal TDD-cycle-evidence
check is **not applicable**. Assertion quality was audited adversarially.

**Backend assertions — GENUINELY BEHAVIORAL (good).** Spot-checked:

- `test_media_index.py::test_replace_scoped_by_service_id_preserves_other_services`:
  inserts two services' rows, replaces the second, asserts the **count** (3, not
  1 — proves the global-clear bug is fixed) and the **id sets** per scoped query
  (`{"a1","a2"}`, `{"b1"}`). No tautology; directly locks in SS-121.
- `test_qbittorrent_client.py`: `test_cookie_reuse_does_not_re_login` asserts
  `session.post.assert_not_called()` after login (proves no re-auth per request);
  `test_403_triggers_re_login` asserts `get` call_count==2 + `post` called once.
- `test_widgets.py` qBit adapters: totals asserts `total` + `by_state` mapping;
  active asserts DL/UL kept and queued/stalled excluded; speed asserts series
  labels (`["download","upload"]`) + last download value.
- `test_services.py::test_cascade_delete_removes_harness_data_across_concerns`:
  asserts qBit A window `== []` after delete while B `len==1`; media A
  `total_a_after==0` while B `total_b_after==1`. Real cross-concern, multi-instance.

**Frontend assertions — adequate, two minor notes.**

- qBit widget tests render the **actual value text** (e.g. `"4"`, state badges
  `"downloading: 1"`) and the error alert — meaningful. The Totals test includes
  an explicit **loading** skeleton case (`isLoading:true` → `[data-slot="skeleton"]`),
  which closes the gap Change A's SC-125 left open. Good.
- `LineSeriesChart.test.tsx` is **smoke-only** (asserts `container.firstChild`
  non-null; does not assert the recharts `<Line>` series rendered). Acceptable as
  a "renders without crashing" guard but does not prove lines drew. Non-blocking.

No ghost loops, no type-only assertions, no implementation-detail CSS assertions,
no tautologies found.

## 6. Review-workload / PR-boundary findings

Per-slice changed lines (numstat; "source" excludes tests/docs):

| Commit | Slice | Source Δ | Total Δ | Over 400? | Verdict |
|--------|-------|----------|---------|-----------|---------|
| `e7bd0af` | 1 harness+store+client+integration | +400 / -0 | +685 / -1 | at budget (source) | **OK** |
| `1fb12b8` | 2 adapter+LineSeriesChart+3 FE widgets | **+644 / -81** | +831 / -81 | **over** | **WARNING** — additive feature code+tests; boundary is exactly qBit widgets (see below) |
| `c87f398` | 3 MediaIndex migration | +46 / -7 | +93 / -7 | under | **OK** |
| `75c949a` | 4 cascade-delete wiring | +13 / -3 | +99 / -22 | under | **OK** |

The `tasks.md` Review Workload Forecast (`stacked-to-main`, 4 slices,
~985–1,205 total) was followed: S1→S2→S3→S4, each independently green. **Slice 2's
+644 source insertions exceed the 400-line budget** and the slice's own
"~350–400" forecast (which under-counted the 3 components + LineSeriesChart
extraction + their tests). No `size:exception` was recorded. This is a
**forecast-vs-actual variance on an additive slice**, not scope creep — the
boundary is exactly the qBit-widget feature and no unrelated files were touched.
Recommend recording the slice-2 actual in the archive summary. **Non-blocking.**

Scope was honored: no backend API/type-contract widening beyond the planned
`jellyfin_service_id` query param (defaulted, backward-compatible); MediaIndex
file location unchanged; no coupling to the unrelated `mobile-responsive-parity`
or `services-as-hub-ia` changes (this change builds/tests green independently).

## 7. Adversarial checks

- **Harness initialized at startup?** **Yes.** `main.py::lifespan` calls
  `get_service_data_harness()` (which lazy-registers both concerns and runs
  migrations) inside a try/except after `ensure_defaults()` (lines 56–60). So
  `cascade_delete` is operational at runtime, not just in tests.
- **qBit client cookie reuse (no re-auth per request)?** **Correct.** `_get`
  only calls `_login()` when `not self._logged_in`; the SID cookie persists in
  the `requests.Session`. `test_cookie_reuse_does_not_re_login` locks this in.
- **Speed timestamp ×1000 vs LineSeriesChart expectation?** **Consistent.**
  Adapter: `{"t": s["ts"] * 1000, ...}` (store keeps unix seconds; ×1000 → JS ms).
  `LineSeriesChart.formatTime(ms)` = `new Date(ms).toLocaleTimeString()`. Same
  `{t:ms, v}` contract Prometheus's `normalize_prometheus_matrix` produces, so
  both chart consumers are interchangeable.
- **Dead code / unregistered concerns?** **None found.** Both
  `QBITTORRENT_CONCERN` and `MEDIA_INDEX_CONCERN` register in
  `get_service_data_harness()`. `SERVICE_ADAPTERS` has `qbittorrent`;
  `SERVICE_DEFINITIONS` has `qbittorrent`; frontend `SERVICE_REGISTRY` has
  `qbittorrent`. `ruff` (catches unused imports) is clean; `tsc`/`eslint` clean.
  (`authentik` is backend-only by design — pre-existing, unrelated.)
- **qBit widget kinds declared consistently in BOTH registries?** **Yes.** Backend
  `integrations/qbittorrent.py` declares `totals`(30s)/`active`(15s)/`speed`(5s);
  frontend `registry.ts` declares the same three kinds with **identical** names,
  descriptions, and refresh intervals. Empty config schemas match (`{type:"object",
  properties:{}, required:[]}` ↔ empty `model_cls`).
- **MediaIndex backward-compat real?** **Yes.** Existing `test_media_index.py`
  calls `replace_items(rows)` / `.query(...)` with no `service_id`; they default
  to `""` → delete `WHERE service_id=''` / no filter → all rows. Full suite (322)
  green, including `test_api.py::TestMediaIndexApi`.

## 8. Residual risks / non-blocking findings

1. **[CRITICAL-process] 30 unchecked tasks + missing `apply-progress.md`** (§4) — archive blocker; reconciliation is doc-only.
2. **[WARNING] Slice 2 over budget** (§6) — +644 source lines vs 400-line budget; additive feature slice, no scope creep; no `size:exception` recorded.
3. **[INFO] `LineSeriesChart.test.tsx` smoke-only** (§5) — asserts mount, not rendered lines.
4. **[INFO] Prune `NOT IN (… LIMIT 120)` edge case** — duplicate `ts` values could in theory keep >120 rows; negligible at 5 s poll.
5. **[INFO] Stale generated `.pi-map.md`** files predate the new modules; not deliverable source. Regenerate.
6. **[INFO] Slice-2 commit hash drift** — brief cited `8b0e7ea`; actual is `1fb12b8`. Content matches; informational.
7. **[INFO] Uncommitted `MediaTab.tsx`** cosmetic reformat + untracked `.pi-tmp/*` (orthogonal to this change). **No files are staged** (`git diff --cached` empty).
8. **[INFO] Chunk-size build warning** (~1.1 MB JS) — non-fatal, pre-existing, orthogonal.
9. **No browser/visual smoke** performed (out of scope); the qBit speed recharts line is only structurally tested.

## 9. Exact blockers

- **BLOCKER (archive only, doc-reconcilable):** 30 unchecked
  implementation/verification tasks (§1.1–5.5) and absent `apply-progress.md`.
  Implementation is verified complete; resolution = tick boxes + write
  `apply-progress.md`.

No code-level blockers. All functional requirements SS-101…SS-128 pass. All four
gates green. **Code is ready for `sdd-sync`; archive requires the
checkbox/apply-progress reconciliation.**

## 10. Recommended next phase

→ **`sdd-sync`** (code PASS). Concurrently/after: author `apply-progress.md`
documenting the four landed slices, and tick §1.1–1.9, §2.1–2.9, §3.1–3.9,
§4.1–4.3, §5.1–5.5 in `tasks.md` to clear the archive blocker. Optionally
strengthen `LineSeriesChart.test.tsx` to assert rendered `<Line>` elements, and
regenerate the stale `.pi-map.md`.

---

### Appendix A — Verification commands run (at `c9404f0`)

```
cd backend && PYTHONPATH=src python3 -m pytest -q           → 322 passed (2 warnings)
cd backend && PYTHONPATH=src python3 -m ruff check src tests → All checks passed!
cd backend && PYTHONPATH=src python3 -m pytest tests/test_service_data.py tests/test_qbittorrent_client.py -q
                                                             → 40 passed
cd frontend && npm run build                                 → exit 0 (✓ built; >500kB warning pre-existing)
cd frontend && npm run lint                                  → exit 0 (0 errors, 1 pre-existing warning)
cd frontend && npx vitest run src/widgets/__tests__/PrometheusChartWidget.test.tsx
                                                             → 4 passed (SS-118 non-regression)
cd frontend && npx vitest run src/widgets/__tests__/ src/components/__tests__/LineSeriesChart.test.tsx
                                                             → 8 files, 31 tests passed
grep -nE '^\s*- \[' openspec/changes/service-storage-harness/tasks.md → 30 unchecked (all tasks)
ls openspec/changes/service-storage-harness/apply-progress.md        → ENOENT (missing)
git diff --cached --name-only                                        → empty (no staged files)
```

### Appendix B — Files substantively changed

**Backend (new)**

- `services/service_data.py` — `StorageConcern`, `ServiceDataHarness` (lifecycle), `get_service_data_harness()`.
- `services/qbittorrent_store.py` — `QBITTORRENT_CONCERN`, `QbittorrentSampleStore` (append/window/prune).
- `clients/qbittorrent.py` — `QbittorrentClient` (cookie login, 403 re-login, maindata).
- `integrations/qbittorrent.py` — `QbittorrentConfig`/`QbittorrentWidgetConfig`/`DEFINITION` (3 widget kinds).

**Backend (modified)**

- `integrations/registry.py` — `qbittorrent` registered in `SERVICE_DEFINITIONS`.
- `widgets/sources.py` — `QbittorrentWidgetSource` (3 branches) + `SERVICE_ADAPTERS["qbittorrent"]`.
- `services/media_index_impl.py` — `MEDIA_INDEX_CONCERN`, `+service_id` column, scoped `replace_items`/`query`, `build_media_index(service_id=...)`.
- `workers/media_index_worker.py` — threads `--service-id` into `build_media_index`.
- `routers/media.py` — `query_media(jellyfin_service_id=...)` → `index.query(service_id=...)`.
- `services/settings_store.py` — `delete_service` calls harness `cascade_delete` (best-effort).
- `main.py` — `lifespan` initializes `get_service_data_harness()`.

**Backend tests (new/extended)**

- `tests/test_service_data.py` (new, 9), `tests/test_qbittorrent_client.py` (new, 12).
- `tests/test_media_index.py` (+scoped regression), `tests/test_widgets.py` (+6 qBit adapter tests), `tests/test_services.py` (+cascade integration).

**Frontend (new/modified)**

- `components/LineSeriesChart.tsx` (new, extracted), `widgets/PrometheusChartWidget.tsx` (thin wrapper).
- `widgets/QbittorrentTotalsWidget.tsx`, `QbittorrentActiveTorrentsWidget.tsx`, `QbittorrentSpeedWidget.tsx` (new).
- `integrations/registry.ts` — `qbittorrent` binding (3 widget kinds).
- Tests: `QbittorrentTotals/Active/SpeedWidget.test.tsx` (new) + `LineSeriesChart.test.tsx` (new).
