diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 5cb90dce9..ea005ff4e 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -226,6 +226,10 @@ show the counted monitored systems coming from agent-backed infrastructure, but the shared API helper must expose the canonical unified-resource grouping explanation instead of rebuilding count reasons from install or registration state. +That shared ledger read must also preserve canonical grouped system status, +including `warning`, so lifecycle-adjacent operator surfaces do not mislabel +live agent-backed infrastructure as `Unknown` when the unified-resource layer +already resolved a governed degraded state. Lifecycle-adjacent workspace copy must also keep the same commercial framing: infrastructure operations may point operators to Pulse Pro for billing, but it must describe that boundary in monitored-system, plan-limit, and license-status diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 0aeb2d1f6..9e5d8c683 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -234,6 +234,11 @@ shared monitored-system explanation summary, sanitized grouping reasons, and included top-level surfaces exactly as the unified-resource resolver computed them, while the frontend client stays in lockstep with that nested payload shape. +That same ledger contract must also preserve the canonical monitored-system +status enum end to end. Backend normalization may fail closed for unsupported +values, but it must not flatten governed `warning` state to `unknown`, because +the billing and inventory surfaces need the real top-level runtime status the +unified-resource resolver computed. That client contract must also fail closed when older or partial payloads omit the nested explanation object: the frontend may normalize missing explanation fields to empty reasons/surfaces plus a safe default summary, but it must not diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index ea3b27e70..27c5d5e05 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -147,6 +147,10 @@ ledger explanation reads: storage- and recovery-adjacent surfaces may coexist with counted monitored-system inventory, but any support-facing count reasoning must come from the canonical unified-resource grouping explanation payload rather than from storage or recovery heuristics. +That adjacent ledger read must also preserve canonical grouped system status, +including `warning`, so recovery- and storage-adjacent support views do not +flatten governed degraded state into a fake `unknown` label when the shared +unified-resource resolver already computed the top-level status. The same API resource serializer also refreshes canonical identity and policy metadata through the shared unified-resource helper before it writes resource payloads, so storage and recovery links inherit the same canonical metadata diff --git a/frontend-modern/src/api/__tests__/monitoredSystemLedger.test.ts b/frontend-modern/src/api/__tests__/monitoredSystemLedger.test.ts index 7a3f062c4..cbbcf377e 100644 --- a/frontend-modern/src/api/__tests__/monitoredSystemLedger.test.ts +++ b/frontend-modern/src/api/__tests__/monitoredSystemLedger.test.ts @@ -84,4 +84,44 @@ describe('MonitoredSystemLedgerAPI', () => { expect(result.systems[0]?.explanation.reasons).toEqual([]); expect(result.systems[0]?.explanation.surfaces).toEqual([]); }); + + it('preserves canonical warning status from the API contract', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + systems: [ + { + name: 'server-1', + type: 'host', + status: 'warning', + last_seen: '2026-01-01T00:00:00Z', + source: 'agent', + }, + ], + total: 1, + limit: 5, + }); + + const result = await MonitoredSystemLedgerAPI.getLedger(); + + expect(result.systems[0]?.status).toBe('warning'); + }); + + it('fails closed to unknown for unsupported status values', async () => { + vi.mocked(apiFetchJSON).mockResolvedValueOnce({ + systems: [ + { + name: 'server-1', + type: 'host', + status: 'degraded', + last_seen: '2026-01-01T00:00:00Z', + source: 'agent', + }, + ], + total: 1, + limit: 5, + }); + + const result = await MonitoredSystemLedgerAPI.getLedger(); + + expect(result.systems[0]?.status).toBe('unknown'); + }); }); diff --git a/frontend-modern/src/api/monitoredSystemLedger.ts b/frontend-modern/src/api/monitoredSystemLedger.ts index c29c75538..9f55e63b1 100644 --- a/frontend-modern/src/api/monitoredSystemLedger.ts +++ b/frontend-modern/src/api/monitoredSystemLedger.ts @@ -1,5 +1,7 @@ import { apiFetchJSON } from '@/utils/apiClient'; +export type MonitoredSystemLedgerStatus = 'online' | 'warning' | 'offline' | 'unknown'; + export interface MonitoredSystemLedgerExplanationReason { kind: string; signal: string; @@ -21,7 +23,7 @@ export interface MonitoredSystemLedgerExplanation { export interface MonitoredSystemLedgerEntry { name: string; type: string; - status: string; // "online" | "offline" | "unknown" + status: MonitoredSystemLedgerStatus; last_seen: string; // RFC3339 or empty source: string; explanation?: MonitoredSystemLedgerExplanation; @@ -51,6 +53,7 @@ function normalizeMonitoredSystemLedgerEntry( const explanation = entry.explanation; return { ...entry, + status: normalizeMonitoredSystemLedgerStatus(entry.status), explanation: { summary: explanation?.summary ?? @@ -60,3 +63,17 @@ function normalizeMonitoredSystemLedgerEntry( }, }; } + +function normalizeMonitoredSystemLedgerStatus( + status: MonitoredSystemLedgerStatus | string | null | undefined, +): MonitoredSystemLedgerStatus { + switch ((status ?? '').trim().toLowerCase()) { + case 'online': + case 'warning': + case 'offline': + case 'unknown': + return status.trim().toLowerCase() as MonitoredSystemLedgerStatus; + default: + return 'unknown'; + } +} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 39ee19750..729fdb03f 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -573,6 +573,77 @@ func TestContract_AIIntelligenceCorrelationsJSONSnapshot(t *testing.T) { assertJSONSnapshot(t, got, want) } +func TestContract_MonitoredSystemLedgerJSONSnapshot(t *testing.T) { + payload := MonitoredSystemLedgerResponse{ + Systems: []MonitoredSystemLedgerEntry{ + { + Name: "Tower", + Type: "host", + Status: "warning", + LastSeen: "2026-03-18T17:30:00Z", + Source: "agent", + Explanation: MonitoredSystemLedgerExplanation{ + Summary: "Counts as one monitored system because Pulse sees one top-level host view from agent.", + Reasons: []MonitoredSystemLedgerExplanationReason{ + { + Kind: "standalone", + Signal: "single-top-level-view", + Summary: "No overlapping top-level source matched this system.", + }, + }, + Surfaces: []MonitoredSystemLedgerExplanationSurface{ + { + Name: "Tower", + Type: "host", + Source: "agent", + }, + }, + }, + }, + }, + Total: 1, + Limit: 5, + } + + got, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal monitored system ledger response: %v", err) + } + + const want = `{ + "systems":[ + { + "name":"Tower", + "type":"host", + "status":"warning", + "last_seen":"2026-03-18T17:30:00Z", + "source":"agent", + "explanation":{ + "summary":"Counts as one monitored system because Pulse sees one top-level host view from agent.", + "reasons":[ + { + "kind":"standalone", + "signal":"single-top-level-view", + "summary":"No overlapping top-level source matched this system." + } + ], + "surfaces":[ + { + "name":"Tower", + "type":"host", + "source":"agent" + } + ] + } + } + ], + "total":1, + "limit":5 + }` + + assertJSONSnapshot(t, got, want) +} + func TestContract_ResolveAuthEnvPathUsesCanonicalRuntimeDataDir(t *testing.T) { envDir := t.TempDir() t.Setenv("PULSE_DATA_DIR", envDir) diff --git a/internal/api/monitored_system_ledger.go b/internal/api/monitored_system_ledger.go index f80cad8ca..2c1e3841b 100644 --- a/internal/api/monitored_system_ledger.go +++ b/internal/api/monitored_system_ledger.go @@ -124,7 +124,7 @@ func (r *Router) handleMonitoredSystemLedger(w http.ResponseWriter, req *http.Re func normalizeStatus(s string) string { switch s { - case "online", "offline": + case "online", "warning", "offline", "unknown": return s default: return "unknown" diff --git a/internal/api/monitored_system_ledger_test.go b/internal/api/monitored_system_ledger_test.go index 876d46f32..9cf648639 100644 --- a/internal/api/monitored_system_ledger_test.go +++ b/internal/api/monitored_system_ledger_test.go @@ -50,7 +50,9 @@ func TestNormalizeStatus(t *testing.T) { want string }{ {"online", "online"}, + {"warning", "warning"}, {"offline", "offline"}, + {"unknown", "unknown"}, {"", "unknown"}, {"degraded", "unknown"}, {"running", "unknown"},