# SDD Verify Report: Per-Instance Hook Scoping

**Change:** `per-instance-hook-scoping`
**Phase:** verify
**Date:** 2026-07-09
**Mode:** Read-only adversarial audit (no source edits, no git push)
**Implementing commit:** `3bc7ce5` (HEAD; single slice, 12 files, +268 / −73)

## Executive summary

| Gate | Command | Result |
|------|---------|--------|
| Backend tests | `cd backend && PYTHONPATH=src python3 -m pytest -q` | **PASS** — 368 passed |
| Backend lint | `cd backend && PYTHONPATH=src python3 -m ruff check src tests` | **PASS** — All checks passed |
| Frontend build | `cd frontend && npm run build` | **PASS** — exit 0 |
| Frontend lint | `cd frontend && npm run lint` | **PASS** — 0 errors, 1 pre-existing warning (unrelated) |
| Frontend tests | `cd frontend && npx vitest run` | **PASS** — 46 files, 165 tests passed |

**Implementation verdict:** All 21 spec requirements (PI-101..PI-121) are satisfied against source, with one deliberate, design-documented scope refinement (the `fetchBackupDashboard` / `get_backup_dashboard` variant was intentionally excluded per design decision 5 — see PI-109/PI-110 notes). The implementation matches the 6 design decisions. Backend filter SQL is parameterized (no injection risk).

**BLOCKING process finding:** The implementation is committed and correct, **but `tasks.md` checkboxes were never updated — 0/17 are checked** and **no `apply-progress.md` artifact exists**. Per the SDD verify contract, unchecked implementation tasks are CRITICAL archive blockers regardless of code state. The work is functionally done; the checkbox/progress hygiene is the gap. This must be reconciled (checkbox reconciliation via apply-progress) before archive.

## Spec coverage (PI-101 .. PI-121)

| Req | Verdict | Evidence |
|-----|---------|----------|
| PI-101 useAlertmanagerAlerts(serviceId) | ✅ PASS | `useObservability.ts`: `queryKey: ["observability","alerts", serviceId ?? ""]`, `queryFn: () => fetchAlertmanagerAlerts(serviceId)`. serviceId in key + fetch. |
| PI-102 useAlertmanagerStatus(serviceId) | ✅ PASS | `queryKey: ["observability","alertmanager-status", serviceId ?? ""]`, arrow queryFn captures serviceId. |
| PI-103 usePrometheusStatus(serviceId) | ✅ PASS | `queryKey: ["observability","prometheus-status", serviceId ?? ""]`, arrow queryFn. |
| PI-104 useBackupJobs(serviceId) | ✅ PASS | `useBackups.ts`: `queryKey: ["backups","jobs", serviceId ?? ""]`. |
| PI-105 useBackupRuns(jobId,status,serviceId) | ✅ PASS | `queryKey: ["backups","runs", jobId, status, serviceId ?? ""]`. |
| PI-106 useBackupAlerts(jobId,ack,sev,serviceId) | ✅ PASS | `queryKey` includes `serviceId ?? ""` as last element. |
| PI-107 usePrometheusTargets + useMonitoringMachines unchanged | ✅ PASS | Neither gains serviceId; both still global, direct `queryFn` refs. MetricsTab keeps `usePrometheusTargets()` global. |
| PI-108 observability fetch fns pass `?service_id` | ✅ PASS | `client.ts`: `fetchAlertmanagerAlerts/Status` + `fetchPrometheusStatus` each `(serviceId?)` → `serviceId ? { service_id: serviceId } : undefined`. |
| PI-109 backup fetch fns pass `?service_id` | ✅ PASS* | `backups.ts`: `fetchBackupJobs`, `fetchBackupRuns` (3rd arg), `fetchBackupAlerts` (4th arg) append `service_id` via conditional spread. **`fetchBackupDashboard` deliberately NOT modified (design decision 5).** *Spec text PI-109 lists `fetchBackupDashboard`; design/tasks override this — see Findings. |
| PI-110 backup endpoints accept service_id | ✅ PASS* | `routers/backups.py`: `get_backup_jobs`, `get_backup_runs`, `get_backup_alerts` each gain `service_id: str \| None = None` and thread it into the store. **`get_backup_dashboard` is in `routers/dashboard.py` and intentionally untouched (design decision 5).** *Spec PI-110 lists it; design overrides. |
| PI-111 SettingsStore filter by service_id | ✅ PASS | `settings_store.py`: `list_backup_jobs` direct `WHERE service_id = ?`; `list_backup_runs` + `list_backup_alerts` use `job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)` subquery. Truthy-check `if service_id:` → None AND "" skip filter (backward-compat). Parameterized — no injection. |
| PI-112 Alertmanager/Prometheus already accept service_id | ✅ PASS (zero backend change, confirmed) | `monitoring.py`: `get_alertmanager_alerts/status` + `get_prometheus_status` already `service_id: str \| None = None` → `resolve_service_record(store, "<type>", service_id)`. No change needed. |
| PI-113 AlertsTab passes instance.id | ✅ PASS | `AlertsTab.tsx`: `useAlertmanagerAlerts(instance.id)` + `useAlertmanagerStatus(instance.id)`. No `void instance;`. TODO removed; docstring updated. |
| PI-114 MetricsTab passes instance.id | ✅ PASS | `MetricsTab.tsx`: `usePrometheusStatus(instance.id)`. `usePrometheusTargets()` stays global. TODO removed. |
| PI-115 JobsTab passes instance.id | ✅ PASS | `JobsTab.tsx`: `useBackupJobs(instance.id)`, `useBackupRuns(undefined, undefined, instance.id)`, `useBackupAlerts(undefined, false, undefined, instance.id)`. TODO removed. |
| PI-116 instance switcher re-scopes | ✅ PASS | `ServicePage.tsx`: sibling switcher `onClick={() => navigate(...)}` → URL param change → `instance` useMemo recomputes → `<TabComponent instance={instance} />` new prop → queryKey change → refetch. Falls out naturally. |
| PI-117 dashboard widgets unaffected | ✅ PASS | Widgets resolve via `useWidgetData` (separate path). Grep confirms no widget/component imports any of the 6 modified hooks. `BackupDashboardWidget` uses `useBackupDashboard` (unchanged) — its test green. Widget tests unchanged & passing. |
| PI-118 all new params optional | ✅ PASS | Every new param is optional with a default preserving today's behavior. Non-regression callers verified: `backup_poller.py` (`list_backup_jobs()`, `list_backup_alerts(acknowledged=False)`), `domain/dashboard.py` (`list_backup_jobs()`, `list_backup_runs(job_id=...,limit=...)`), `build_backup_dashboard_summary(store)` all call with no service_id → return all. |
| PI-119 backend tests + lint green | ✅ PASS | `TestBackupServiceScoping` covers jobs/runs/alerts filtered + unfiltered (incl. empty-string) + endpoint threading. 368 pytest pass; ruff clean. |
| PI-120 frontend typecheck/build/lint | ✅ PASS | `npm run build` exit 0; `npm run lint` 0 errors (1 pre-existing warning in `WidgetConfigDialog.tsx`, unrelated). |
| PI-121 hook + tab tests cover wiring | ✅ PASS (1 quality note) | `AlertsTab.test.tsx` asserts `vi.mocked(useAlertmanagerAlerts).toHaveBeenCalledWith("am-1")` — strong assertion. Hook test `useBackups.test.ts` exists covering all 6 hooks. **Quality note:** the hook-test "different keys" assertion (`expect(a).not.toBe(b)` on renderHook result refs) is weak/tautology-prone — see Findings. |

**Coverage: 21/21 requirements satisfied against source.**

## Adversarial checks

- **Any tab still calling a scoped hook without instance.id?** No. All three tabs pass `instance.id`; no remaining `void instance;` anywhere in `service-tabs/`.
- **Instance switcher actually re-scopes?** Yes — `navigate(...)` re-derives `instance` from URL → queryKey changes → refetch (PI-116).
- **Dashboard widget regression?** None. `fetchBackupDashboard` / `useBackupDashboard` / `get_backup_dashboard` / `build_backup_dashboard_summary` all untouched; `BackupDashboardWidget` test green.
- **Subquery SQL injection?** Safe — both subqueries use `?` placeholders with `params.append(service_id)`. No string interpolation of user input.
- **Cache-key collision?** All 6 modified hooks include `serviceId ?? ""` as last queryKey element (verified per hook). Single stable key for undefined; distinct key per instance.

## Structured status & actionContext findings

- Native status engine reports `state: blocked`, `taskProgress 0/17`, `applyProgress: missing`, `nextRecommended: "domain specs are missing or partial"`, with `blockedReasons` citing missing domain specs + legacy flat spec present without domain specs.
- **These blockers are artifact-hygiene, not implementation gaps.** The code is committed and all gates are green. The substantive blockers to archive are:
  1. `tasks.md` checkboxes: 0/17 checked (all still `- [ ]`). Implementation is done; checkboxes were not reconciled.
  2. No `apply-progress.md` artifact exists.
  3. No domain `specs/` deltas — a flat `spec.md` is present without domain spec packages (same pattern the status engine flags). This is a sync-stage concern (sync reconciles flat→domain), not a verify blocker; verify is satisfied.
- `actionContext.mode: repo-local`, `allowedEditRoots: ["/home/user/manage"]` — implementation files are all within allowed roots. Ownership proven.

## Strict TDD

Strict TDD is **not active** in `openspec/config.yaml` for this workflow (no strict-TDD directive present; this verify run was not gated on a TDD cycle-evidence table). N/A.

## Review workload / PR boundary

- `tasks.md` forecast: single PR, ~257 lines, low budget risk, no chained PRs.
- Actual: single commit `3bc7ce5`, +268/−73, 12 files — matches the single-slice forecast. No scope creep. No chained-PR boundary violation.

## Findings

### Blocking

- **BLOCKER-1 (CRITICAL — archive blocker; process hygiene, NOT code): `tasks.md` checkboxes unreconciled — 0/17 checked, no `apply-progress.md`.** The implementation for all 17 sub-tasks is present and verified, but `tasks.md` still shows every task as `- [ ]` (e.g. `- [ ] **1.1 Add service_id filter to list_backup_jobs**` … `- [ ] **1.17 Verify Slice 1 …**`). Per the SDD verify contract, unchecked implementation tasks are CRITICAL archive blockers. The native status engine correctly reflects this (0/17). **Exact unchecked lines (all 17):**
  - `- [ ] **1.1 Add \`service_id\` filter to \`list_backup_jobs\`**`
  - `- [ ] **1.2 Add \`service_id\` filter to \`list_backup_runs\` (subquery)**`
  - `- [ ] **1.3 Add \`service_id\` filter to \`list_backup_alerts\` (subquery)**`
  - `- [ ] **1.4 Add backend tests for backup service scoping**`
  - `- [ ] **1.5 Add \`service_id\` query param to backup endpoints**`
  - `- [ ] **1.6 Add backend test for endpoint threading**`
  - `- [ ] **1.7 Confirm Alertmanager/Prometheus endpoints need no change**`
  - `- [ ] **1.8 Add \`serviceId\` param to observability fetch functions**`
  - `- [ ] **1.9 Add \`serviceId\` param to backup fetch functions (NOT Dashboard)**`
  - `- [ ] **1.10 Add \`serviceId\` to \`useObservability\` hooks**`
  - `- [ ] **1.11 Add \`serviceId\` to \`useBackups\` hooks**`
  - `- [ ] **1.12 Wire \`instance.id\` into AlertsTab**`
  - `- [ ] **1.13 Wire \`instance.id\` into MetricsTab**`
  - `- [ ] **1.14 Wire \`instance.id\` into JobsTab**`
  - `- [ ] **1.15 Add hook test for per-instance queryKey**`
  - `- [ ] **1.16 Add tab test for \`instance.id\` wiring**`
  - `- [ ] **1.17 Verify Slice 1 (build + lint + test green)**`
  - **Resolution (apply/sync phase, not verify):** create `apply-progress.md` recording the work done in `3bc7ce5`, then flip the 17 checkboxes to `[x]`. This is a stale-checkbox reconciliation explicitly permitted by the archive-exception clause once apply-progress proves the work.

### Non-blocking

- **NB-1 (spec↔design wording mismatch — amend spec):** Spec PI-109 and PI-110 literally list `fetchBackupDashboard` / `get_backup_dashboard` as in-scope for a new service param. Design decision 5 and tasks 1.5/1.9 deliberately exclude them (dashboard widget path, PI-117 risk). The implementation correctly follows the design. **Recommend** amending `spec.md` PI-109/PI-110 to drop the dashboard variant so spec and design agree. No functional defect.
- **NB-2 (weak assertion quality, PI-121 hook test):** `useBackups.test.ts` "different keys for different serviceIds" asserts `expect(a).not.toBe(b)` on two `renderHook` result objects. This is trivially true (each `renderHook` returns a new reference) and does **not** actually assert the `queryKey` differs — the test would pass even if queryKeys collided. The hook wiring is correct in source (manually verified), and the **tab** test (`AlertsTab.test.tsx` `toHaveBeenCalledWith("am-1")`) is strong, so PI-121 is satisfied. Recommend strengthening the hook test to inspect `queryClient.getQueryCache().getAll()` keys or assert `queryFn` (mocked `fetchBackupJobs`) is invoked with the distinct serviceId.
- **NB-3 (project-map staleness):** Several `.pi-map.md` artifacts still describe the pre-change signatures (e.g. `list_backup_jobs(self)` without `service_id`, `fetchBackupJobs()`, router docs without service_id). These are generated map docs, not source. Cosmetic; reconcile via `project_map_patch` when convenient. Not part of this change's source scope.

## Commands run (exact)

| Command | Result |
|---------|--------|
| `cd backend && PYTHONPATH=src python3 -m pytest -q` | 368 passed, 2 warnings |
| `cd backend && PYTHONPATH=src python3 -m pytest tests/test_backups.py -q -k "ServiceScoping or endpoint_threads or filtered or unfiltered"` | 6 passed, 2 deselected |
| `cd backend && PYTHONPATH=src python3 -m ruff check src tests` | All checks passed! |
| `cd frontend && npm run build` | exit 0 (`tsc -b` + `vite build`) |
| `cd frontend && npm run lint` | 0 errors, 1 warning (pre-existing, `WidgetConfigDialog.tsx`) |
| `cd frontend && npx vitest run` | 46 files, 165 tests passed |

## Changed files (commit 3bc7ce5)

- `backend/src/media_library_viewer_api/routers/backups.py`
- `backend/src/media_library_viewer_api/services/settings_store.py`
- `backend/tests/test_backups.py`
- `frontend/src/api/backups.ts`
- `frontend/src/api/client.ts`
- `frontend/src/hooks/useBackups.ts`
- `frontend/src/hooks/useObservability.ts`
- `frontend/src/hooks/__tests__/useBackups.test.ts` (new)
- `frontend/src/pages/service-tabs/AlertsTab.tsx`
- `frontend/src/pages/service-tabs/MetricsTab.tsx`
- `frontend/src/pages/service-tabs/JobsTab.tsx`
- `frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx` (updated)

## Conclusion

The `per-instance-hook-scoping` change is **functionally complete and correct**: all 21 requirements pass against source, all 5 gates are green, non-regression is confirmed, and the subquery SQL is injection-safe. The **only blocker is process hygiene** — `tasks.md` checkboxes are stale (0/17) and `apply-progress.md` is missing. Reconcile those (write apply-progress, flip checkboxes) to clear the archive gate. No code changes are required.
