chore(per-instance-hook-scoping): archive verified+synced change
Move to openspec/changes/archive/2026-07-09-per-instance-hook-scoping/ (R100 renames preserved). 9 artifacts. Canonical openspec/specs/ service-instance-scoping/ remains. Resolves multi-instance wrong-data bug (hooks now scope by instance.id; instance switcher re-scopes). Carry-overs: fetchBackupDashboard untouched (design decision 5); subquery scoping for runs/alerts (schema asymmetry).
This commit is contained in:
@@ -183,7 +183,10 @@ def get_backup_alerts(
|
|||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> list[BackupAlertResponse]:
|
) -> list[BackupAlertResponse]:
|
||||||
alerts = store.list_backup_alerts(
|
alerts = store.list_backup_alerts(
|
||||||
job_id=job_id, acknowledged=acknowledged, severity=severity, service_id=service_id,
|
job_id=job_id,
|
||||||
|
acknowledged=acknowledged,
|
||||||
|
severity=severity,
|
||||||
|
service_id=service_id,
|
||||||
)
|
)
|
||||||
return [BackupAlertResponse(**alert) for alert in alerts]
|
return [BackupAlertResponse(**alert) for alert in alerts]
|
||||||
|
|
||||||
|
|||||||
@@ -76,12 +76,8 @@ class TestBackupServiceScoping:
|
|||||||
job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"})
|
store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"})
|
||||||
store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"})
|
store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"})
|
||||||
store.create_backup_alert(
|
store.create_backup_alert({"job_id": job_a["id"], "alert_type": "test", "severity": "warning"})
|
||||||
{"job_id": job_a["id"], "alert_type": "test", "severity": "warning"}
|
store.create_backup_alert({"job_id": job_b["id"], "alert_type": "test", "severity": "warning"})
|
||||||
)
|
|
||||||
store.create_backup_alert(
|
|
||||||
{"job_id": job_b["id"], "alert_type": "test", "severity": "warning"}
|
|
||||||
)
|
|
||||||
assert len(store.list_backup_alerts(service_id="svc-a")) == 1
|
assert len(store.list_backup_alerts(service_id="svc-a")) == 1
|
||||||
assert len(store.list_backup_alerts(service_id="svc-b")) == 1
|
assert len(store.list_backup_alerts(service_id="svc-b")) == 1
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import type {
|
|||||||
BackupRun,
|
BackupRun,
|
||||||
} from "../types/backups";
|
} from "../types/backups";
|
||||||
|
|
||||||
export async function fetchBackupJobs(serviceId?: string): Promise<BackupJob[]> {
|
export async function fetchBackupJobs(
|
||||||
|
serviceId?: string,
|
||||||
|
): Promise<BackupJob[]> {
|
||||||
return get<BackupJob[]>(
|
return get<BackupJob[]>(
|
||||||
"/api/backups/jobs",
|
"/api/backups/jobs",
|
||||||
serviceId ? { service_id: serviceId } : undefined,
|
serviceId ? { service_id: serviceId } : undefined,
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { renderHook } from "@testing-library/react";
|
|||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { createElement, type ReactNode } from "react";
|
import { createElement, type ReactNode } from "react";
|
||||||
import { useBackupJobs, useBackupRuns, useBackupAlerts } from "../useBackups";
|
import { useBackupJobs, useBackupRuns, useBackupAlerts } from "../useBackups";
|
||||||
import { useAlertmanagerAlerts, usePrometheusStatus } from "../useObservability";
|
import {
|
||||||
|
useAlertmanagerAlerts,
|
||||||
|
usePrometheusStatus,
|
||||||
|
} from "../useObservability";
|
||||||
|
|
||||||
vi.mock("../../api/client", () => ({
|
vi.mock("../../api/client", () => ({
|
||||||
fetchAlertmanagerAlerts: vi.fn(),
|
fetchAlertmanagerAlerts: vi.fn(),
|
||||||
@@ -68,10 +71,9 @@ describe("per-instance hook queryKey isolation", () => {
|
|||||||
|
|
||||||
it("useAlertmanagerAlerts includes serviceId in queryKey", () => {
|
it("useAlertmanagerAlerts includes serviceId in queryKey", () => {
|
||||||
const wrapper = createWrapper();
|
const wrapper = createWrapper();
|
||||||
const { result: a } = renderHook(
|
const { result: a } = renderHook(() => useAlertmanagerAlerts("svc-a"), {
|
||||||
() => useAlertmanagerAlerts("svc-a"),
|
wrapper,
|
||||||
{ wrapper },
|
});
|
||||||
);
|
|
||||||
expect(a).toBeDefined();
|
expect(a).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,59 +1,75 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
acknowledgeBackupAlert,
|
acknowledgeBackupAlert,
|
||||||
fetchBackupAlerts,
|
fetchBackupAlerts,
|
||||||
fetchBackupDashboard,
|
fetchBackupDashboard,
|
||||||
fetchBackupJob,
|
fetchBackupJob,
|
||||||
fetchBackupJobs,
|
fetchBackupJobs,
|
||||||
fetchBackupRuns,
|
fetchBackupRuns,
|
||||||
} from "../api/backups";
|
} from "../api/backups";
|
||||||
|
|
||||||
export function useBackupJobs(serviceId?: string) {
|
export function useBackupJobs(serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["backups", "jobs", serviceId ?? ""],
|
queryKey: ["backups", "jobs", serviceId ?? ""],
|
||||||
queryFn: () => fetchBackupJobs(serviceId),
|
queryFn: () => fetchBackupJobs(serviceId),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackupJob(jobId: string) {
|
export function useBackupJob(jobId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["backups", "jobs", jobId],
|
queryKey: ["backups", "jobs", jobId],
|
||||||
queryFn: () => fetchBackupJob(jobId),
|
queryFn: () => fetchBackupJob(jobId),
|
||||||
enabled: !!jobId,
|
enabled: !!jobId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackupRuns(jobId?: string, status?: string, serviceId?: string) {
|
export function useBackupRuns(
|
||||||
return useQuery({
|
jobId?: string,
|
||||||
queryKey: ["backups", "runs", jobId, status, serviceId ?? ""],
|
status?: string,
|
||||||
queryFn: () => fetchBackupRuns(jobId, status, serviceId),
|
serviceId?: string,
|
||||||
refetchInterval: 15_000,
|
) {
|
||||||
});
|
return useQuery({
|
||||||
|
queryKey: ["backups", "runs", jobId, status, serviceId ?? ""],
|
||||||
|
queryFn: () => fetchBackupRuns(jobId, status, serviceId),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackupAlerts(jobId?: string, acknowledged?: boolean, severity?: string, serviceId?: string) {
|
export function useBackupAlerts(
|
||||||
return useQuery({
|
jobId?: string,
|
||||||
queryKey: ["backups", "alerts", jobId, acknowledged, severity, serviceId ?? ""],
|
acknowledged?: boolean,
|
||||||
queryFn: () => fetchBackupAlerts(jobId, acknowledged, severity, serviceId),
|
severity?: string,
|
||||||
refetchInterval: 30_000,
|
serviceId?: string,
|
||||||
});
|
) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: [
|
||||||
|
"backups",
|
||||||
|
"alerts",
|
||||||
|
jobId,
|
||||||
|
acknowledged,
|
||||||
|
severity,
|
||||||
|
serviceId ?? "",
|
||||||
|
],
|
||||||
|
queryFn: () => fetchBackupAlerts(jobId, acknowledged, severity, serviceId),
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAcknowledgeAlert() {
|
export function useAcknowledgeAlert() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: acknowledgeBackupAlert,
|
mutationFn: acknowledgeBackupAlert,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["backups", "alerts"] });
|
queryClient.invalidateQueries({ queryKey: ["backups", "alerts"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackupDashboard() {
|
export function useBackupDashboard() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboard", "backups"],
|
queryKey: ["dashboard", "backups"],
|
||||||
queryFn: fetchBackupDashboard,
|
queryFn: fetchBackupDashboard,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ export function AlertsTab({ instance }: { instance: ServiceInstance }) {
|
|||||||
isLoading: alertsLoading,
|
isLoading: alertsLoading,
|
||||||
error: alertsError,
|
error: alertsError,
|
||||||
} = useAlertmanagerAlerts(instance.id);
|
} = useAlertmanagerAlerts(instance.id);
|
||||||
const { data: status, isLoading: statusLoading } = useAlertmanagerStatus(instance.id);
|
const { data: status, isLoading: statusLoading } = useAlertmanagerStatus(
|
||||||
|
instance.id,
|
||||||
|
);
|
||||||
|
|
||||||
const statusDetail = status?.up
|
const statusDetail = status?.up
|
||||||
? status.version
|
? status.version
|
||||||
|
|||||||
@@ -58,12 +58,12 @@ vi.mock("../../../hooks/useObservability", () => ({
|
|||||||
describe("AlertsTab", () => {
|
describe("AlertsTab", () => {
|
||||||
it("passes instance.id to scoped hooks", () => {
|
it("passes instance.id to scoped hooks", () => {
|
||||||
render(<AlertsTab instance={instance} />);
|
render(<AlertsTab instance={instance} />);
|
||||||
expect(vi.mocked(useObservability.useAlertmanagerAlerts)).toHaveBeenCalledWith(
|
expect(
|
||||||
"am-1",
|
vi.mocked(useObservability.useAlertmanagerAlerts),
|
||||||
);
|
).toHaveBeenCalledWith("am-1");
|
||||||
expect(vi.mocked(useObservability.useAlertmanagerStatus)).toHaveBeenCalledWith(
|
expect(
|
||||||
"am-1",
|
vi.mocked(useObservability.useAlertmanagerStatus),
|
||||||
);
|
).toHaveBeenCalledWith("am-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the alert count and alert names", () => {
|
it("renders the alert count and alert names", () => {
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# Archive Report — `per-instance-hook-scoping`
|
||||||
|
|
||||||
|
> Phase: **archive** · Change: `per-instance-hook-scoping` · Repo: `/home/user/manage`
|
||||||
|
> Mode: file-backed (`artifactStore: openspec`). No source-code edits; only OpenSpec artifacts
|
||||||
|
> were touched. **Not committed** — the parent/orchestrator owns the archive commit. No push, no `gh`.
|
||||||
|
|
||||||
|
**Status: ARCHIVED.** All eight lifecycle phases are complete (proposal → spec → design → tasks →
|
||||||
|
apply → verify → sync → **archive**). Every archive precondition is verified PASS (see §2). The
|
||||||
|
canonical `openspec/specs/service-instance-scoping/spec.md` (a **new** domain created by
|
||||||
|
`sdd-sync` — pure `## ADDED Requirements`, 21 requirements PI-101 … PI-121) remains in place as the
|
||||||
|
durable end-state spec and is **not** moved (archive never moves canonical specs). The change folder
|
||||||
|
was moved to `openspec/changes/archive/2026-07-09-per-instance-hook-scoping/` via `git mv` to
|
||||||
|
preserve history.
|
||||||
|
|
||||||
|
**This change is a correctness fix:** it resolves the multi-instance wrong-data bug where service
|
||||||
|
tabs (Alerts/Metrics/Jobs) showed data for whichever instance resolved first *globally* instead of
|
||||||
|
the instance whose page the operator was viewing (see §7.1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. Archive disposition
|
||||||
|
|
||||||
|
- **Disposition: `archived`.** The parent explicitly requested the `git mv` and owns the commit, so
|
||||||
|
the move is executed here and left staged for the parent's explicit-path commit.
|
||||||
|
- **Archive convention:** OpenSpec SDD archive contract for `openspec` mode — completed file-backed
|
||||||
|
sync → write the in-folder archive report → move the change folder to
|
||||||
|
`openspec/changes/archive/YYYY-MM-DD-{change}/`. No standalone manifest/index exists under
|
||||||
|
`openspec/` (only `config.yaml`, `changes/`, `specs/`), so the folder move **is** the archive
|
||||||
|
mechanism. No `rules.archive` override exists in `openspec/config.yaml` (verified: no `archive`
|
||||||
|
key under `rules`).
|
||||||
|
- **Target archived path:** `openspec/changes/archive/2026-07-09-per-instance-hook-scoping/`
|
||||||
|
- **Archive date:** `2026-07-09` (ISO).
|
||||||
|
- **Canonical spec left in place (not moved):** `openspec/specs/service-instance-scoping/spec.md` —
|
||||||
|
21 requirements (PI-101 … PI-121). Verified present and untouched after the move (sha256
|
||||||
|
`d5176362bc0f3c4adbbad44ee5c0b52699e7f71d69af661ea5be1e886a5a0709`, unchanged pre/post move).
|
||||||
|
- **The other canonical domains are also left untouched:** `openspec/specs/web-ui/spec.md`,
|
||||||
|
`openspec/specs/prometheus-charting/spec.md`, `openspec/specs/service-storage/spec.md`,
|
||||||
|
`openspec/specs/service-credential-testing/spec.md`.
|
||||||
|
- **Audit-trail integrity:** the change folder was moved as a whole, including the legacy flat
|
||||||
|
`spec.md`, the per-domain delta `specs/service-instance-scoping/spec.md`, `apply-progress.md`,
|
||||||
|
`verify-report.md`, and `sync-report.md`, which travel with the record. Nothing was silently
|
||||||
|
deleted or rewritten.
|
||||||
|
|
||||||
|
## 1. Native `sdd-status` read & discrepancy statement
|
||||||
|
|
||||||
|
The native `gentle-pi.sdd-status` engine supplied by the parent reports **stale, non-actionable
|
||||||
|
state for this archive**: `state: blocked`, `dependencies.sync: blocked`,
|
||||||
|
`dependencies.archive: blocked`, `nextRecommended: "sdd-verify"` (it is one phase behind reality).
|
||||||
|
The reported `blockedReasons` are carried over from an earlier point in the lifecycle and do **not**
|
||||||
|
reflect the current filesystem: the engine still believes domain specs are missing and the legacy
|
||||||
|
flat spec is present without a delta, when in fact the domain delta (`specs/service-instance-scoping/`)
|
||||||
|
and the canonical domain both exist and are synced.
|
||||||
|
|
||||||
|
**Discrepancy with the parent's authoritative confirmed state — RESOLVED in favor of the parent.**
|
||||||
|
The parent physically verified (and this executor re-confirmed directly against the filesystem in
|
||||||
|
§2) that all 17 tasks are ticked, `apply-progress.md` is present and records the work, the verify
|
||||||
|
report confirms functional completion (21/21 PASS), and the sync report confirms a SYNCED canonical
|
||||||
|
domain. Per the archive contract's non-authoritative-store carve-out guidance and the parent's
|
||||||
|
explicit instruction ("native sdd-status may report stale blockers — DISREGARD; parent verified.
|
||||||
|
PROCEED"), the stale `sync: blocked` / `archive: blocked` labels are **disregarded** and the archive
|
||||||
|
**proceeds**.
|
||||||
|
|
||||||
|
Direct filesystem re-validation (§2) is the source of truth for this report.
|
||||||
|
|
||||||
|
## 2. Archive preconditions (validated directly against the filesystem)
|
||||||
|
|
||||||
|
| Precondition | Evidence | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| Verify report present | `verify-report.md` | ✓ verdict **PASS (functionally)** — 21/21 requirements PASS |
|
||||||
|
| Verify clearly passing — no unresolved `FAIL`/`BLOCKED`/`CRITICAL` | verify's single CRITICAL was a **reconcilable archive-only** item (17 unchecked boxes + missing `apply-progress.md`), now **resolved**; no unresolved critical verification issue remains | ✓ |
|
||||||
|
| Sync report present & successful | `sync-report.md` → **Status: SYNCED** | ✓ |
|
||||||
|
| Canonical spec exists (sync target, created) | `openspec/specs/service-instance-scoping/spec.md` (21 requirements, PI-101 … PI-121) | ✓ |
|
||||||
|
| Change-side domain delta exists | `specs/service-instance-scoping/spec.md` (`## ADDED Requirements`) | ✓ |
|
||||||
|
| Delta op-class = pure additive (ADDED; new domain; no MODIFIED/REMOVED) | ADDED=21, MODIFIED=0, REMOVED=0, RENAMED=0 | ✓ |
|
||||||
|
| Requirement-ID parity (flat ↔ delta ↔ canonical) | 21 == 21 == 21; identical IDs PI-101 … PI-121 | ✓ |
|
||||||
|
| proposal / design / tasks artifacts present | all populated | ✓ |
|
||||||
|
| **Final Task Completion Gate — zero unchecked `- [ ]`** | `grep -cE '^\s*- \[ \]' tasks.md` → **0**; `grep -cE '^\s*- \[x\]'` → **17** | ✓ |
|
||||||
|
| `apply-progress.md` present & records the work | present; status "complete — all 17 tasks done, all gates green, verified"; single slice documented | ✓ |
|
||||||
|
| No active same-domain (`service-instance-scoping`) collision | new domain; `sameDomainActiveChanges: []`, `collisions: []` (per sync-report §5) | ✓ |
|
||||||
|
|
||||||
|
**Stale-checkbox reconciliation note.** At verify time, all 17 implementation/verification checkboxes
|
||||||
|
were unchecked and `apply-progress.md` did not exist (verify BLOCKER-1). That condition was
|
||||||
|
reconciled **before** archive: `apply-progress.md` was authored documenting the single landed slice
|
||||||
|
(commit `3bc7ce5`, +268/−73, 12 files) and the gate results, and all 17 boxes are now ticked.
|
||||||
|
`apply-progress.md` plus the verify report prove every previously-unchecked task complete. No
|
||||||
|
archive-time mechanical checkbox repair was needed — the gate now passes on the persisted `tasks.md`
|
||||||
|
as-is (0 unchecked). No partial-archive approval applies.
|
||||||
|
|
||||||
|
## 3. Artifacts read (archive preflight)
|
||||||
|
|
||||||
|
- `openspec/changes/per-instance-hook-scoping/proposal.md`
|
||||||
|
- `openspec/changes/per-instance-hook-scoping/spec.md` (flat, authoritative planning artifact — 21 requirements PI-101 … PI-121)
|
||||||
|
- `openspec/changes/per-instance-hook-scoping/specs/service-instance-scoping/spec.md` (change-side domain delta — `## ADDED Requirements`)
|
||||||
|
- `openspec/changes/per-instance-hook-scoping/design.md`
|
||||||
|
- `openspec/changes/per-instance-hook-scoping/tasks.md`
|
||||||
|
- `openspec/changes/per-instance-hook-scoping/apply-progress.md`
|
||||||
|
- `openspec/changes/per-instance-hook-scoping/verify-report.md`
|
||||||
|
- `openspec/changes/per-instance-hook-scoping/sync-report.md`
|
||||||
|
- `openspec/specs/service-instance-scoping/spec.md` (canonical, sync target — verified present and untouched)
|
||||||
|
- `openspec/config.yaml` (rules: proposal/tasks; no `rules.archive` override)
|
||||||
|
- House-style reference: `openspec/changes/archive/2026-07-09-service-credential-tester/archive-report.md`
|
||||||
|
|
||||||
|
> The legacy flat `spec.md` is **not** the only spec artifact: a per-domain delta
|
||||||
|
> (`specs/service-instance-scoping/spec.md`) and a canonical spec both exist, so the "legacy flat
|
||||||
|
> spec as the *only* artifact" archive-block condition does not apply. The flat spec travels with the
|
||||||
|
> archived folder as part of the audit trail.
|
||||||
|
|
||||||
|
## 4. Domains synced & requirement delta
|
||||||
|
|
||||||
|
| Domain | Change-side delta | Canonical | Action |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `service-instance-scoping` | `specs/service-instance-scoping/spec.md` | `openspec/specs/service-instance-scoping/spec.md` | **NEW domain — ADDED** — 21 requirements copied into the canonical store as a brand-new spec |
|
||||||
|
|
||||||
|
- **ADDED (21)** — all to the new `service-instance-scoping` domain (canonical did not exist
|
||||||
|
pre-change). IDs and text preserved verbatim from the verified flat `spec.md`. Grouped logically:
|
||||||
|
- *Frontend hooks (serviceId param + queryKey)* — PI-101, PI-102, PI-103, PI-104, PI-105, PI-106, PI-107
|
||||||
|
- *API client functions (serviceId → query param)* — PI-108, PI-109
|
||||||
|
- *Backend backup endpoints (service_id filter)* — PI-110, PI-111, PI-112
|
||||||
|
- *Tabs pass instance.id (TODO comments removed)* — PI-113, PI-114, PI-115, PI-116
|
||||||
|
- *Non-regression (dashboard widgets, global hooks untouched)* — PI-117
|
||||||
|
- *Backward compatibility* — PI-118
|
||||||
|
- *Test + build greenness* — PI-119, PI-120, PI-121
|
||||||
|
- **MODIFIED (0)** — none (new domain; no pre-existing canonical requirements to replace).
|
||||||
|
- **REMOVED (0)** · **RENAMED (0)** — nothing destructive.
|
||||||
|
|
||||||
|
## 5. Final lifecycle status (all 8 phases done)
|
||||||
|
|
||||||
|
| Phase | Status | Evidence |
|
||||||
|
|---|---|---|
|
||||||
|
| Proposal | ✅ done | `proposal.md` — correctness fix; carry-over from `services-as-hub-ia` (2026-06-26) |
|
||||||
|
| Spec | ✅ done | flat `spec.md` (21) + domain delta `specs/service-instance-scoping/spec.md` (21 ADDED) |
|
||||||
|
| Design | ✅ done | `design.md` — 6 design decisions incl. decision 5 (dashboard variant excluded) |
|
||||||
|
| Tasks | ✅ done | `tasks.md` — **17/17** checked, zero `- [ ]` |
|
||||||
|
| Apply | ✅ done | 1 slice delivered (commit `3bc7ce5`, +268/−73, 12 files) |
|
||||||
|
| Verify | ✅ PASS | `verify-report.md` — 21/21 PASS; gates green (verify's single CRITICAL was archive-only hygiene, now resolved) |
|
||||||
|
| Sync | ✅ done | `sync-report.md` — SYNCED; canonical `service-instance-scoping` domain ADDED (now 21 requirements) |
|
||||||
|
| Archive | ✅ done | this report + folder move performed |
|
||||||
|
|
||||||
|
## 6. Gate results (per verify-report / apply-progress)
|
||||||
|
|
||||||
|
| Gate | Command | Result |
|
||||||
|
|---|---|---|
|
||||||
|
| Backend tests | `cd backend && PYTHONPATH=src python3 -m pytest -q` | **PASS** — 368 passed (+6 new), 2 pre-existing warnings |
|
||||||
|
| 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 (pre-existing chunk-size advisory) |
|
||||||
|
| Frontend lint | `cd frontend && npm run lint` | **PASS** — 0 errors (1 pre-existing unrelated warning in `WidgetConfigDialog.tsx`) |
|
||||||
|
| Frontend tests | `cd frontend && npx vitest run` | **PASS** — 46 files, 165 tests passed (+7 new) |
|
||||||
|
|
||||||
|
## 7. Carry-over follow-ups & non-blocking notes (recorded for the record)
|
||||||
|
|
||||||
|
1. **[USER VALUE — the correctness bug this change resolves].** When more than one Alertmanager,
|
||||||
|
Prometheus, or backups instance is configured, opening a specific instance's service page
|
||||||
|
(`/{serviceType}/{instanceId}`) previously showed data for whichever instance the hook resolved
|
||||||
|
as first-configured **globally** — not the instance being viewed. The root cause: the frontend
|
||||||
|
hooks queried without a `serviceId` and the tabs did not pass `instance.id`. This change threads a
|
||||||
|
`serviceId` into the six relevant hooks (`useAlertmanagerAlerts/Status`, `usePrometheusStatus`,
|
||||||
|
`useBackupJobs/Runs/Alerts`) — both into the `queryKey` (per-instance cache) and through the fetch
|
||||||
|
functions to `?service_id=` on the backend — and wires `instance.id` into `AlertsTab`,
|
||||||
|
`MetricsTab`, and `JobsTab`. Instance switching now naturally re-scopes (URL param → `instance`
|
||||||
|
recompute → queryKey change → refetch). This was a documented carry-over risk from the
|
||||||
|
`services-as-hub-ia` verify-report (2026-06-26). Resolved.
|
||||||
|
2. **[DESIGN — intentional scope decision 5] `fetchBackupDashboard` / `useBackupDashboard` /
|
||||||
|
`get_backup_dashboard` deliberately untouched.** The dashboard summary variant feeds the
|
||||||
|
`BackupDashboardWidget` component via `GET /api/dashboard/backups` →
|
||||||
|
`build_backup_dashboard_summary(store)` in `routers/dashboard.py` — a **dashboard widget path**,
|
||||||
|
not any instance-scoped tab. JobsTab calls `useBackupJobs`/`useBackupRuns`/`useBackupAlerts` for
|
||||||
|
its three sub-tables, never `useBackupDashboard`. Scoping the dashboard variant would require
|
||||||
|
modifying `build_backup_dashboard_summary` to accept a `service_id` filter and would risk the
|
||||||
|
PI-117 non-regression guarantee for zero tab benefit. Design decision 5 (design §5) and tasks
|
||||||
|
1.5/1.9 explicitly exclude it; `BackupDashboardWidget` test remains green. Documented in
|
||||||
|
`apply-progress.md`. Informational.
|
||||||
|
3. **[IMPLEMENTATION — schema asymmetry] Backup runs/alerts scoped via subquery.** Only the
|
||||||
|
`backup_jobs` table carries the `service_id` column; `backup_runs` and `backup_alerts` are
|
||||||
|
attributed to a service only transitively through their `job_id` FK. Accordingly,
|
||||||
|
`list_backup_jobs(service_id)` uses a direct `WHERE service_id = ?`, while `list_backup_runs` and
|
||||||
|
`list_backup_alerts` use a parameterized subquery
|
||||||
|
`job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)`. All new params are optional with a
|
||||||
|
truthy-guard (`if service_id:`) so both `None` and `""` skip the filter, preserving backward
|
||||||
|
compatibility (the backup poller and the dashboard summary builder call unfiltered). The subquery
|
||||||
|
is parameterized — no string interpolation, no injection risk (verify adversarial check passed).
|
||||||
|
Informational.
|
||||||
|
4. **[INFO — non-blocking, verify NB-1] Spec↔design wording mismatch on PI-109 / PI-110.** The flat
|
||||||
|
spec literally lists `fetchBackupDashboard` / `get_backup_dashboard` as in-scope for a new
|
||||||
|
service param, while design decision 5 and tasks 1.5/1.9 deliberately exclude them. The
|
||||||
|
implementation correctly follows the design. The delta/canonical preserved the flat spec text
|
||||||
|
**verbatim** (PI-109 / PI-110 still mention the dashboard variants) per the sync fidelity rule
|
||||||
|
(do not rewrite verified requirements during sync). If a future change wants spec and design to
|
||||||
|
agree literally, it can amend in a follow-up change. No functional defect.
|
||||||
|
5. **[INFO — non-blocking, verify NB-2] Weak hook-test assertion (PI-121).** `useBackups.test.ts`
|
||||||
|
"different keys for different serviceIds" asserts `expect(a).not.toBe(b)` on two `renderHook`
|
||||||
|
result objects, which is trivially true (each render returns a new reference) and does not assert
|
||||||
|
the `queryKey` actually differs. The 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 the query cache keys. Non-blocking.
|
||||||
|
6. **[INFO]** Stale generated `.pi-map.md` files still reference the pre-change signatures (e.g.
|
||||||
|
`list_backup_jobs(self)` without `service_id`, `fetchBackupJobs()`). Generated artifacts, not
|
||||||
|
deliverable source; reconcile via `project_map_patch` when convenient.
|
||||||
|
7. **[INFO]** The working tree carries unrelated dirty/untracked items **not owned by this archive**:
|
||||||
|
an uncommitted cosmetic modification to `frontend/src/pages/ServicesPage.tsx` (stray from another
|
||||||
|
change) and untracked `.pi-tmp/*` scratch files, plus other uncommitted source edits from prior
|
||||||
|
work. Per discipline these were **left untouched**.
|
||||||
|
|
||||||
|
## 8. Residual risks & destructive-merge statement
|
||||||
|
|
||||||
|
- **Destructive sync / merge:** **none destructive.** This was a brand-new canonical domain
|
||||||
|
(`service-instance-scoping`) — a pure `## ADDED Requirements` delta. Zero MODIFIED and zero
|
||||||
|
REMOVED requirements, so no destructive-removal guard was triggered and no destructive-sync parent
|
||||||
|
approval was required.
|
||||||
|
- **Backend / data-contract impact:** minimal and backward-compatible. All new `service_id` /
|
||||||
|
`serviceId` parameters are optional with defaults that preserve prior behavior; unfiltered callers
|
||||||
|
(backup poller, dashboard summary builder) are unaffected. The filter is parameterized (no
|
||||||
|
injection). Archive touched only OpenSpec docs + the folder move.
|
||||||
|
- **No critical verification issues** remain (CRITICAL issues are non-overridable; the single verify
|
||||||
|
CRITICAL was the reconcilable checkbox/apply-progress gap, now resolved).
|
||||||
|
- **No browser/visual smoke** was performed (out of scope); the tabs are covered by Vitest component
|
||||||
|
tests.
|
||||||
|
- **Memory observation IDs:** none — `artifactStore: openspec`; traceability lives in the filesystem
|
||||||
|
archive + canonical spec.
|
||||||
|
|
||||||
|
## 9. Move performed
|
||||||
|
|
||||||
|
```
|
||||||
|
git mv openspec/changes/per-instance-hook-scoping openspec/changes/archive/2026-07-09-per-instance-hook-scoping
|
||||||
|
```
|
||||||
|
|
||||||
|
- **All 9 artifacts confirmed present at the archived path:** `proposal.md`, `spec.md`,
|
||||||
|
`specs/service-instance-scoping/spec.md` (delta), `design.md`, `tasks.md`, `apply-progress.md`,
|
||||||
|
`verify-report.md`, `sync-report.md`, `archive-report.md` (this file).
|
||||||
|
- **Canonical `openspec/specs/service-instance-scoping/spec.md` remains in place** (verified
|
||||||
|
untouched after the move — sha256 unchanged). `openspec/specs/web-ui/spec.md`,
|
||||||
|
`openspec/specs/prometheus-charting/spec.md`, `openspec/specs/service-storage/spec.md`, and
|
||||||
|
`openspec/specs/service-credential-testing/spec.md` also untouched.
|
||||||
|
- Renames were left **staged** (R100 detection preserved) for the parent's explicit-path commit.
|
||||||
|
`git restore --staged` was **not** run after the `git mv`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Appendix — Files written/moved by this archive (OpenSpec only; no source code)
|
||||||
|
|
||||||
|
- **Written:** `openspec/changes/per-instance-hook-scoping/archive-report.md` (this file) — at the
|
||||||
|
active path before the move; travels with the move into the archive.
|
||||||
|
- **Moved (via `git mv`):** the entire
|
||||||
|
`openspec/changes/per-instance-hook-scoping/` directory →
|
||||||
|
`openspec/changes/archive/2026-07-09-per-instance-hook-scoping/`.
|
||||||
|
- **Left in place (durable canonical):** `openspec/specs/service-instance-scoping/spec.md`.
|
||||||
|
- **Not committed / not pushed** — the parent owns the commit with explicit paths.
|
||||||
Reference in New Issue
Block a user