# Per-Instance Hook Scoping — Delta (`per-instance-hook-scoping`) > Change: `per-instance-hook-scoping` · Domain: `service-instance-scoping` · Phase: **spec** (reconciled during `sdd-sync`). > Distilled verbatim from the verified flat `spec.md` (21 requirements, PI-101 … PI-121) of change > `per-instance-hook-scoping`, cross-referenced against `design.md` and `verify-report.md`. Captures > the **durable, post-change end-state contracts** for scoping the observability and backup > service-type tabs/hooks to the operator-selected instance, instead of whichever instance resolves > first globally. ## ADDED Requirements > The canonical `openspec/specs/service-instance-scoping/spec.md` did not exist before this change. All > requirements below are therefore **ADDED** to a new `service-instance-scoping` domain; `sdd-sync` > copies them into the canonical spec (native helper rule: when the canonical spec does not exist, > the change spec becomes the new canonical spec). > > Requirement IDs (PI-101 … PI-121) and body text are preserved **exactly** from the verified flat > `spec.md`. Requirements are grouped logically and listed in the following group order: > > - **Frontend hooks (serviceId param + queryKey)** — PI-101 … PI-107 > - **API client functions (serviceId → query param)** — PI-108 … PI-109 > - **Backend backup endpoints (service_id filter)** — PI-110 … PI-112 > - **Tabs pass instance.id (TODO comments removed)** — PI-113 … PI-116 > - **Non-regression (dashboard widgets, global hooks untouched)** — PI-117 > - **Backward compatibility** — PI-118 > - **Test + build greenness** — PI-119 … PI-121 ### Requirement: PI-101 — useAlertmanagerAlerts accepts serviceId The hook `useAlertmanagerAlerts` MUST accept an optional `serviceId?: string` argument. When provided, the hook MUST (a) include the `serviceId` in its TanStack Query `queryKey` (so caches do not collide across instances), and (b) pass the `serviceId` to its underlying fetch function. When omitted, the hook MUST behave identically to today (global/first-configured resolution). #### Scenario: per-instance cache key - GIVEN two configured Alertmanager instances A and B - WHEN `useAlertmanagerAlerts("A")` and `useAlertmanagerAlerts("B")` are both mounted - THEN their `queryKey`s MUST differ (the `serviceId` appears in the key) - AND a cache hit for A MUST NOT be served for B (no stale cross-instance data) #### Scenario: omitted serviceId is backward-compatible - GIVEN a caller that does not pass `serviceId` - WHEN the hook runs - THEN its behavior MUST match the pre-change hook (queryKey shape equivalent, fetch path identical) ### Requirement: PI-102 — useAlertmanagerStatus accepts serviceId The hook `useAlertmanagerStatus` MUST accept an optional `serviceId?: string`, include it in its `queryKey`, and pass it to its fetch function. Omitting it MUST preserve today's behavior. #### Scenario: per-instance cache key - GIVEN instances A and B - WHEN `useAlertmanagerStatus("A")` and `useAlertmanagerStatus("B")` are mounted - THEN the `queryKey`s MUST differ and a cache hit for A MUST NOT serve B ### Requirement: PI-103 — usePrometheusStatus accepts serviceId The hook `usePrometheusStatus` MUST accept an optional `serviceId?: string`, include it in its `queryKey`, and pass it to its fetch function. Omitting it MUST preserve today's behavior. #### Scenario: per-instance cache key - GIVEN instances A and B - WHEN `usePrometheusStatus("A")` and `usePrometheusStatus("B")` are mounted - THEN the `queryKey`s MUST differ and a cache hit for A MUST NOT serve B ### Requirement: PI-104 — useBackupJobs accepts serviceId The hook `useBackupJobs` MUST accept an optional `serviceId?: string`, include it in its `queryKey`, and pass it to its fetch function. Omitting it MUST preserve today's behavior. #### Scenario: per-instance cache key - GIVEN backup services A and B - WHEN `useBackupJobs("A")` and `useBackupJobs("B")` are mounted - THEN the `queryKey`s MUST differ and a cache hit for A MUST NOT serve B ### Requirement: PI-105 — useBackupRuns accepts serviceId The hook `useBackupRuns(jobId, status, serviceId?)` MUST accept an optional `serviceId?: string` in addition to its existing `jobId` and `status` arguments, include it in its `queryKey`, and pass it to its fetch function. Omitting it MUST preserve today's behavior. #### Scenario: per-instance cache key - GIVEN backup services A and B - WHEN `useBackupRuns("job1", "all", "A")` and `useBackupRuns("job1", "all", "B")` are mounted - THEN the `queryKey`s MUST differ ### Requirement: PI-106 — useBackupAlerts accepts serviceId The hook `useBackupAlerts(jobId, acknowledged, severity, serviceId?)` MUST accept an optional `serviceId?: string` in addition to its existing arguments, include it in its `queryKey`, and pass it to its fetch function. Omitting it MUST preserve today's behavior. #### Scenario: per-instance cache key - GIVEN backup services A and B - WHEN `useBackupAlerts("job1", false, "all", "A")` and `useBackupAlerts("job1", false, "all", "B")` are mounted - THEN the `queryKey`s MUST differ ### Requirement: PI-107 — usePrometheusTargets and useMonitoringMachines stay global The hooks `usePrometheusTargets` and `useMonitoringMachines` MUST NOT gain a `serviceId` parameter. - `usePrometheusTargets` returns Node Exporter scrape targets for *external* Prometheus instances via `http_sd_configs` (cross-instance by design). - `useMonitoringMachines` is a global cross-service concept (not per-instance). #### Scenario: global hooks unchanged - GIVEN the change is applied - WHEN `usePrometheusTargets()` and `useMonitoringMachines()` signatures are inspected - THEN neither accepts a `serviceId` arg and both behave identically to before ### Requirement: PI-108 — Observability fetch functions pass service_id query param The fetch functions `fetchAlertmanagerAlerts`, `fetchAlertmanagerStatus`, and `fetchPrometheusStatus` (in `api/client.ts`) MUST each accept an optional `serviceId?: string`. When provided, the function MUST append `service_id=` to the request URL as a query parameter (correctly using `?` for the first param and `&` for subsequent ones). When omitted, the request MUST be identical to today (no `service_id` param sent). #### Scenario: serviceId appended - GIVEN `fetchPrometheusStatus("svc-abc")` is called - WHEN the request is issued - THEN the URL contains `service_id=svc-abc` #### Scenario: omitted serviceId is a no-op - GIVEN `fetchPrometheusStatus()` is called (no serviceId) - WHEN the request is issued - THEN the URL MUST NOT contain `service_id` ### Requirement: PI-109 — Backup fetch functions pass service_id query param The fetch functions `fetchBackupJobs`, `fetchBackupRuns`, `fetchBackupAlerts`, and `fetchBackupDashboard` (in `api/backups.ts`) MUST each accept an optional `serviceId?: string`. When provided, the function MUST append `service_id=` to the request URL. When omitted, the request MUST be identical to today. #### Scenario: serviceId appended alongside existing params - GIVEN `fetchBackupRuns("job1", "failed", "svc-xyz")` is called - WHEN the request is issued - THEN the URL contains both `job_id=job1&status=failed` (existing params) AND `service_id=svc-xyz` #### Scenario: omitted serviceId is a no-op - GIVEN `fetchBackupJobs()` is called (no serviceId) - WHEN the request is issued - THEN the URL MUST NOT contain `service_id` ### Requirement: PI-110 — Backup endpoints accept optional service_id The endpoints `get_backup_jobs`, `get_backup_runs`, `get_backup_alerts`, and `get_backup_dashboard` in `routers/backups.py` MUST each accept an optional `service_id: str | None = None` query parameter and thread it into the corresponding `SettingsStore` query method. #### Scenario: service_id passed through to store - GIVEN a request `GET /api/backups/jobs?service_id=svc-xyz` - WHEN the endpoint runs - THEN `store.list_backup_jobs` MUST be called with `service_id="svc-xyz"` ### Requirement: PI-111 — SettingsStore backup queries filter by service_id when provided The methods `list_backup_jobs`, `list_backup_runs`, and `list_backup_alerts` in `services/settings_store.py` MUST each accept an optional `service_id: str | None` parameter. When `service_id` is a non-null, non-empty string, the query MUST include a `WHERE service_id = ?` clause (or equivalent scoping) returning only rows attributed to that service. When `service_id` is `None` or empty, the query MUST return ALL rows (no filter) — preserving backward-compatibility for existing callers. The `backup_jobs` and `backup_runs` tables already carry `service_id` attribution (from the `services-as-hub-ia` Slice 3 work); this requirement adds the filter, not the column. #### Scenario: filtered to one service - GIVEN the store holds jobs for services A and B - WHEN `list_backup_jobs(service_id="A")` is called - THEN only service A's jobs MUST be returned #### Scenario: no filter returns all - GIVEN the store holds jobs for services A and B - WHEN `list_backup_jobs(service_id=None)` is called - THEN jobs for BOTH services MUST be returned (backward-compat) #### Scenario: empty string is treated as no filter - GIVEN the store holds jobs for services A and B - WHEN `list_backup_jobs(service_id="")` is called - THEN jobs for BOTH services MUST be returned (defensive against falsy strings) ### Requirement: PI-112 — Alertmanager/Prometheus status endpoints already accept service_id The endpoints `get_alertmanager_alerts`, `get_alertmanager_status`, and `get_prometheus_status` in `routers/monitoring.py` ALREADY accept a `service_id: str | None = None` query parameter and resolve via `resolve_service_record(store, "", service_id)`. No backend change is required for these. This requirement exists to document the asymmetry: the frontend wiring change for these hooks is necessary (PI-108 + PI-101..103), but the backend endpoint work is zero. #### Scenario: existing endpoint behavior confirmed - GIVEN the change is applied - WHEN `get_alertmanager_alerts(service_id="svc-a")` is called - THEN it MUST resolve to the `svc-a` Alertmanager instance (pre-existing behavior, unchanged) ### Requirement: PI-113 — AlertsTab passes instance.id The component `pages/service-tabs/AlertsTab.tsx` MUST call `useAlertmanagerAlerts(instance.id)` and `useAlertmanagerStatus(instance.id)` (passing the `instance.id` it already receives as a prop). The existing TODO comment about "hooks don't accept a service_id yet" MUST be removed. #### Scenario: data is instance-scoped - GIVEN two Alertmanager instances A and B are configured - WHEN the operator navigates to instance A's service page - THEN AlertsTab MUST show alerts sourced from instance A (not B, not first-resolved) ### Requirement: PI-114 — MetricsTab passes instance.id The component `pages/service-tabs/MetricsTab.tsx` MUST call `usePrometheusStatus(instance.id)` (passing the `instance.id` it already receives as a prop). The existing TODO comment MUST be removed. (`usePrometheusTargets` stays global — PI-107.) #### Scenario: data is instance-scoped - GIVEN two Prometheus instances A and B are configured - WHEN the operator navigates to instance A's service page - THEN MetricsTab MUST show status for instance A (not B, not first-resolved) ### Requirement: PI-115 — JobsTab passes instance.id The component `pages/service-tabs/JobsTab.tsx` MUST call `useBackupJobs(instance.id)`, `useBackupRuns(, , instance.id)`, and `useBackupAlerts(, , , instance.id)`. The existing TODO comment MUST be removed. #### Scenario: data is instance-scoped - GIVEN two backups service instances A and B are configured - WHEN the operator navigates to instance A's service page - THEN JobsTab MUST show jobs/runs/alerts for service A only ### Requirement: PI-116 — Instance switcher re-scopes tab data The `ServicePage` instance switcher (present when `enabledSiblings.length > 1`) changes the active instance. When the operator switches from instance A to instance B, the three tabs MUST re-fetch and re-render with instance B's data. (This falls out naturally from passing `instance.id` into the hook `queryKey`s — switching the prop changes the key, triggering a fresh fetch — but it is verified explicitly because it's the user-visible correctness goal.) #### Scenario: switch re-scopes - GIVEN the operator is viewing instance A's MetricsTab (data from A) - WHEN the operator uses the instance switcher to select instance B - THEN the tab MUST re-render with instance B's status (not A's cached data) ### Requirement: PI-117 — Dashboard widgets render identically before/after Dashboard widgets (`AlertmanagerAlertsWidget`, `BackupsWidget`, `PrometheusMetricWidget`/`Metric*`, etc.) resolve their service via the widget-instance's `service_id` binding through `useWidgetData` — a different code path from the hooks changed here. They MUST render identically before and after this change. The widget component tests MUST remain unchanged and green. #### Scenario: widget path unaffected - GIVEN the change is applied - WHEN a dashboard widget fetches its data - THEN the fetch path (`useWidgetData(widget.id, …)`) MUST be unchanged; no `useAlertmanager*` / `useBackup*` hook is on the widget data path ### Requirement: PI-118 — All new params are optional; omitting equals today's behavior Every new parameter added by this change (hook `serviceId?: string`, fetch-function `serviceId?: string`, endpoint `service_id: str | None = None`, store-method `service_id: str | None = None`) MUST be optional with a default that preserves today's behavior. No existing caller is forced to pass the new argument. Existing callers that omit it MUST see identical results to the pre-change code. #### Scenario: existing callers unbroken - GIVEN any pre-existing caller of `useBackupJobs()`, `fetchBackupRuns()`, `GET /api/backups/jobs`, or `store.list_backup_jobs()` - WHEN the change is applied and the caller is run without modification - THEN the behavior MUST be identical to before (no error, same data returned) ### Requirement: PI-119 — Backend tests + lint green `PYTHONPATH=src python3 -m pytest -q` and `PYTHONPATH=src python3 -m ruff check src tests` from `backend/` MUST pass. New backend tests MUST cover: `list_backup_*` filtering by `service_id` (filtered, unfiltered, empty-string); backup endpoint threading `service_id` into the store. #### Scenario: backup filter tests exist - GIVEN the change is applied - WHEN `pytest tests/test_backups.py` (or equivalent) runs - THEN it MUST include a test asserting `service_id="A"` returns only A's rows AND `service_id=None` returns all rows ### Requirement: PI-120 — Frontend typechecks, builds, and lints `npm run build` (which runs `tsc -b` + `vite build`) and `npm run lint` from `frontend/` MUST pass (0 errors; pre-existing warnings are acceptable). ### Requirement: PI-121 — Hook + tab tests cover the new serviceId wiring The frontend test suite MUST include: - A hook test asserting the `serviceId` appears in the `queryKey` (per-instance cache isolation) for at least one representative hook (e.g. `useBackupJobs`). - A tab test asserting `instance.id` is passed through to the hook (for at least one of AlertsTab / MetricsTab / JobsTab). #### Scenario: per-instance queryKey test exists - GIVEN the change is applied - WHEN the test suite runs - THEN a test MUST assert that calling the hook with different `serviceId` values produces different `queryKey`s #### Scenario: tab passes instance.id test exists - GIVEN the change is applied - WHEN the test suite runs - THEN a test MUST assert that at least one tab component passes its received `instance.id` to the scoped hook