# 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 `` 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 WHERE = ?`. 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` → ``. 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` → ``). `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 `` 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 `` 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).