diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 23f266bf8..ff4ba0ef2 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1343,6 +1343,18 @@ the canonical monitored-system blocked payload. ## Current State +The TS client `frontend-modern/src/api/resourceOperatorState.ts` +mirrors the canonical Go shape from +`internal/unifiedresources/resource_operator_state.go` and exposes +`getResourceOperatorState`, `setResourceOperatorState`, and +`clearResourceOperatorState` against the +`/api/resources/{id}/operator-state` endpoint. The GET path +normalizes the server's `404 operator_state_not_set` response into +`null` so callers see "no state recorded" as a clean default rather +than a thrown error; non-404 errors propagate. The PUT path +percent-encodes the canonical resource id segment so colon-bearing +ids round-trip safely through URL routing. + The router wires the operator-state adapter into the findings runtime at startup: `internal/api/router.go` calls `patrol.GetFindings().SetResourceOperatorStateProvider(...)` with a diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index a1ee11461..bcb49bf23 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -1247,6 +1247,15 @@ prompt explain the same operator-facing priority. ## Current State +`ResourceOperatorStateSection.tsx` on the resource detail drawer +overview tab uses `createNonSuspendingQuery` to fetch +`/api/resources/{id}/operator-state` so the drawer's parent +Suspense boundary does not flicker the page-level "Loading view…" +fallback while operator-set state is in flight. New self-fetching +sections inside the drawer must follow the same pattern (or wrap in +their own local Suspense) rather than relying on `createResource`, +which propagates suspension to the closest ancestor. + The Patrol page header copy lives in a single canonical helper at `frontend-modern/src/utils/patrolPagePresentation.ts`. The page-title tooltip on `PatrolIntelligenceHeader.tsx` must read from diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 048fb9db2..288a104fe 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -1175,6 +1175,25 @@ and Explain entry points. Investigation evidence and rollback plans are intentionally omitted from the clipboard shape — those are conversation context for the Assistant flow, not "share this finding" context for chat or tickets. +The resource detail drawer now exposes the operator-set state via the +`ResourceOperatorStateSection` component on the Overview tab. The +section sits alongside `ResourceActionHistory` so the "what overrides +has the operator set" and "what actions has Pulse taken" stories read +together, and routes through the canonical +`@/api/resourceOperatorState` TS client with no parallel fetch path. +The two boolean toggles (`IntentionallyOffline`, +`NeverAutoRemediate`) are dirty-tracked locally with explicit +Save/Discard actions; flipping `NeverAutoRemediate` true requires an +explicit confirmation prompt because it's a safety override that +locks the resource against all automated remediation, while flipping +it false (releasing the lock) is permissive. Maintenance windows are +displayed read-only this slice — when an active window covers `now`, +the section badges it; scheduling lives in a separate slice that +owns the date-picker UX. The section uses `createNonSuspendingQuery` +rather than `createResource` so the drawer's parent Suspense +boundary does not flicker the page-level fallback while operator +state is in flight. + The findings store also consumes per-resource operator-set state via the narrow `ResourceOperatorStateProvider` interface installed by `SetResourceOperatorStateProvider`. The API layer wires an adapter diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 755bb7e88..81fc3ad84 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -579,9 +579,20 @@ and lower-cases the criticality value before persistence. The `ClearResourceOperatorState`; both the SQLite (table `resource_operator_state` keyed on `canonical_id`) and Memory stores implement the same upsert + idempotent-clear contract. The -`/api/resources/{id}/operator-state` API surface (GET / PUT / DELETE) -in `internal/api/resources_operator_state.go` is the operator-facing -consumer of this contract; the URL canonical_id always wins over the +operator-facing surface for this contract is +`ResourceOperatorStateSection.tsx` on the resource detail drawer +overview tab, which routes through the canonical TS client at +`frontend-modern/src/api/resourceOperatorState.ts` to +`/api/resources/{id}/operator-state`. The drawer integration is +read-only on maintenance windows (the section badges an active +window when `now` falls within it but does not let the operator +schedule one — that lives in a follow-up slice). The two boolean +toggles (intentionally offline, never auto-remediate) are the +operator-facing primitives this slice surfaces. + +The `/api/resources/{id}/operator-state` API surface (GET / PUT / +DELETE) in `internal/api/resources_operator_state.go` is the +operator-facing consumer of this contract; the URL canonical_id always wins over the body, server-side `setAt` / `setBy` populate from request time and authenticated identity, and validation rejections surface a stable `operator_state_invalid` error code. The action broker diff --git a/frontend-modern/src/api/__tests__/resourceOperatorState.test.ts b/frontend-modern/src/api/__tests__/resourceOperatorState.test.ts new file mode 100644 index 000000000..6fc672dbc --- /dev/null +++ b/frontend-modern/src/api/__tests__/resourceOperatorState.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: vi.fn(), +})); + +import { + clearResourceOperatorState, + getResourceOperatorState, + setResourceOperatorState, + type ResourceOperatorState, + type ResourceOperatorStateInput, +} from '@/api/resourceOperatorState'; +import { apiFetchJSON } from '@/utils/apiClient'; + +describe('resourceOperatorState api', () => { + const apiFetchJSONMock = vi.mocked(apiFetchJSON); + + beforeEach(() => { + apiFetchJSONMock.mockReset(); + }); + + it('encodes the resource id segment so colon-bearing canonical ids round-trip safely', async () => { + apiFetchJSONMock.mockResolvedValueOnce({ + canonicalId: 'instance:node:101', + intentionallyOffline: false, + neverAutoRemediate: false, + setAt: '2026-05-09T10:00:00Z', + } satisfies ResourceOperatorState); + + await getResourceOperatorState('instance:node:101'); + + expect(apiFetchJSONMock).toHaveBeenCalledWith( + // colons are reserved in URL paths and must be percent-encoded + // before the canonical id reaches the server router. + '/api/resources/instance%3Anode%3A101/operator-state', + { cache: 'no-store' }, + ); + }); + + it('returns null when the server reports operator_state_not_set as 404', async () => { + apiFetchJSONMock.mockRejectedValueOnce( + Object.assign(new Error('Not found'), { status: 404 }), + ); + + await expect(getResourceOperatorState('vm:101')).resolves.toBeNull(); + }); + + it('rethrows non-404 errors so the caller can surface them to the operator', async () => { + apiFetchJSONMock.mockRejectedValueOnce( + Object.assign(new Error('Internal server error'), { status: 500 }), + ); + + await expect(getResourceOperatorState('vm:101')).rejects.toThrow('Internal server error'); + }); + + it('PUTs the canonical body shape and returns the read-after-write record', async () => { + apiFetchJSONMock.mockResolvedValueOnce({ + canonicalId: 'vm:101', + intentionallyOffline: true, + neverAutoRemediate: false, + setAt: '2026-05-09T11:00:00Z', + setBy: 'operator:richard', + } satisfies ResourceOperatorState); + + const input: ResourceOperatorStateInput = { + intentionallyOffline: true, + neverAutoRemediate: false, + }; + const result = await setResourceOperatorState('vm:101', input); + + expect(apiFetchJSONMock).toHaveBeenCalledWith( + '/api/resources/vm%3A101/operator-state', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify(input), + }), + ); + // The returned record carries server-populated attribution + // (setAt, setBy) — never echo the input verbatim. + expect(result.setAt).toBe('2026-05-09T11:00:00Z'); + expect(result.setBy).toBe('operator:richard'); + }); + + it('DELETEs without expecting a body response', async () => { + apiFetchJSONMock.mockResolvedValueOnce(undefined as never); + + await expect(clearResourceOperatorState('vm:101')).resolves.toBeUndefined(); + expect(apiFetchJSONMock).toHaveBeenCalledWith( + '/api/resources/vm%3A101/operator-state', + { method: 'DELETE' }, + ); + }); +}); diff --git a/frontend-modern/src/api/resourceOperatorState.ts b/frontend-modern/src/api/resourceOperatorState.ts new file mode 100644 index 000000000..3afcb4a35 --- /dev/null +++ b/frontend-modern/src/api/resourceOperatorState.ts @@ -0,0 +1,109 @@ +import { apiFetchJSON } from '@/utils/apiClient'; + +/** + * Operator-set per-resource intent. Mirrors the canonical Go shape from + * `internal/unifiedresources/resource_operator_state.go` with explicit JSON + * field names so the TS surface stays decoupled from the storage type's + * evolution. See the patrol-intelligence and ai-runtime subsystem contracts + * for the suppression / refusal semantics each field drives. + */ +export interface ResourceOperatorState { + canonicalId: string; + /** + * When true, new findings raised against this resource get + * auto-acknowledged with reason=expected_behavior — Patrol stops + * notifying about a resource the operator has marked + * "intentionally offline" (e.g. a deprecated workload, dev environment + * shut down on purpose, archived host). + */ + intentionallyOffline: boolean; + /** + * When true, the action broker refuses to dispatch automated + * remediation against this resource even with a valid approval and + * matching plan hash. The refusal is persisted as a Failed audit + * record with `resource_remediation_locked:` prefix on the error. + */ + neverAutoRemediate: boolean; + /** + * Maintenance window — when present and `now` falls within it, all + * new findings on this resource get auto-acknowledged with + * reason=expected_behavior + cause=maintenance_window. Both start + * and end must be set together (server validates). + */ + maintenanceStartAt?: string; + maintenanceEndAt?: string; + maintenanceReason?: string; + /** + * Optional operator hint that affects finding sort order. One of + * `'high' | 'medium' | 'low' | ''` (empty = default). + */ + criticality?: 'high' | 'medium' | 'low' | ''; + note?: string; + setAt: string; + setBy?: string; +} + +/** + * The PUT body shape — same as the read model but with attribution + * stripped because the server populates `setAt` and `setBy` from the + * authenticated identity, ignoring any client-supplied values. + */ +export type ResourceOperatorStateInput = Omit< + ResourceOperatorState, + 'canonicalId' | 'setAt' | 'setBy' +>; + +/** + * Read the operator-set state for a resource. Resolves to null when + * the server returns 404 (no entry recorded — the default no-state + * posture). Throws on other errors. + */ +export async function getResourceOperatorState( + resourceId: string, +): Promise { + try { + return await apiFetchJSON( + `/api/resources/${encodeURIComponent(resourceId)}/operator-state`, + { cache: 'no-store' }, + ); + } catch (err) { + // The 404 response shape is `{ error: 'operator_state_not_set', ... }`. + // Translating into null lets the caller treat "no state" as a clean + // default rather than a thrown error. + if (err && typeof err === 'object' && 'status' in err && (err as { status: number }).status === 404) { + return null; + } + throw err; + } +} + +/** + * Replace the operator-set state for a resource. The server populates + * `setAt` and `setBy` server-side, so the input shape excludes them. + * Returns the persisted record (read-after-write) so the caller can + * see exactly what landed, including the attribution fields. + */ +export async function setResourceOperatorState( + resourceId: string, + state: ResourceOperatorStateInput, +): Promise { + return apiFetchJSON( + `/api/resources/${encodeURIComponent(resourceId)}/operator-state`, + { + method: 'PUT', + body: JSON.stringify(state), + headers: { 'Content-Type': 'application/json' }, + }, + ); +} + +/** + * Remove any operator-set state for the resource. Idempotent — resolves + * cleanly whether or not an entry was present. + */ +export async function clearResourceOperatorState(resourceId: string): Promise { + await apiFetchJSON( + `/api/resources/${encodeURIComponent(resourceId)}/operator-state`, + { method: 'DELETE' }, + ); +} diff --git a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx index fbbdd0dd7..6f22db263 100644 --- a/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx +++ b/frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx @@ -28,6 +28,7 @@ import { ResourceCorrelationSummary } from './ResourceCorrelationSummary'; import { ResourceChangeSummary } from './ResourceChangeSummary'; import { ResourceFacetSummary } from './ResourceFacetSummary'; import { ResourceActionHistory } from './ResourceActionHistory'; +import { ResourceOperatorStateSection } from './ResourceOperatorStateSection'; import { RESOURCE_CHANGE_KIND_ORDER, RESOURCE_CHANGE_SOURCE_ADAPTER_ORDER, @@ -561,6 +562,15 @@ export const ResourceDetailDrawerOverviewTab: Component + {/* Operator-set per-resource state (intentionally offline, + never auto-remediate) sits next to the action history so + the "what overrides has the operator set" and "what actions + has Pulse taken" stories read together. The section is + self-fetching keyed on the canonical resource id. */} + + + + = ( + props, +) => { + // Fetch the persisted state via the non-suspending helper so the + // drawer's parent Suspense boundary does not flicker the page-level + // fallback while operator-state is in flight. null means "no entry" + // (the default no-state posture). + const query = createNonSuspendingQuery({ + source: () => props.resourceId || null, + fetcher: async (id: string) => { + try { + return await getResourceOperatorState(id); + } catch (err) { + notificationStore.error( + err instanceof Error ? err.message : 'Failed to load operator state', + ); + return null; + } + }, + initialValue: null, + cacheKey: (id: string) => `resource-operator-state:${id}`, + }); + const persisted = query.value; + + // Local edit state, hydrated from the persisted record. Operators can + // toggle either flag and the section dirty-tracks until they hit Save + // or Discard. + const [intentionallyOffline, setIntentionallyOffline] = createSignal(false); + const [neverAutoRemediate, setNeverAutoRemediate] = createSignal(false); + const [saving, setSaving] = createSignal(false); + const [confirmingLock, setConfirmingLock] = createSignal(false); + + // Hydrate edit state from persisted record on first load and on resource change. + createEffect(() => { + const current = persisted(); + if (current === undefined) return; + setIntentionallyOffline(current?.intentionallyOffline ?? false); + setNeverAutoRemediate(current?.neverAutoRemediate ?? false); + setConfirmingLock(false); + }); + + const isDirty = createMemo(() => { + const current = persisted(); + const persistedOffline = current?.intentionallyOffline ?? false; + const persistedLocked = current?.neverAutoRemediate ?? false; + return ( + intentionallyOffline() !== persistedOffline || + neverAutoRemediate() !== persistedLocked + ); + }); + + // The lock toggle is a safety override — confirm before flipping to + // true. Flipping back to false from true is just a release and does + // not need confirmation. + const handleNeverAutoRemediateToggle = (next: boolean) => { + if (next && !neverAutoRemediate()) { + setConfirmingLock(true); + return; + } + setNeverAutoRemediate(next); + }; + + const confirmLockToggle = () => { + setNeverAutoRemediate(true); + setConfirmingLock(false); + }; + + const cancelLockToggle = () => { + setConfirmingLock(false); + }; + + const handleSave = async () => { + setSaving(true); + try { + const current = persisted(); + const input: ResourceOperatorStateInput = { + intentionallyOffline: intentionallyOffline(), + neverAutoRemediate: neverAutoRemediate(), + // Preserve any maintenance-window data the API currently holds + // — this slice owns toggles only; window scheduling is a + // separate slice and clobbering it on save would surprise the + // operator. + maintenanceStartAt: current?.maintenanceStartAt, + maintenanceEndAt: current?.maintenanceEndAt, + maintenanceReason: current?.maintenanceReason, + criticality: current?.criticality, + note: current?.note, + }; + await setResourceOperatorState(props.resourceId, input); + // Refresh from server so the section displays the persisted + // attribution (setAt / setBy populated server-side). + await query.refetch(); + notificationStore.success('Operator overrides saved'); + } catch (err) { + notificationStore.error( + err instanceof Error ? err.message : 'Failed to save operator overrides', + ); + } finally { + setSaving(false); + } + }; + + const handleDiscard = () => { + const current = persisted(); + setIntentionallyOffline(current?.intentionallyOffline ?? false); + setNeverAutoRemediate(current?.neverAutoRemediate ?? false); + setConfirmingLock(false); + }; + + const handleClear = async () => { + setSaving(true); + try { + await clearResourceOperatorState(props.resourceId); + // Refresh from server — DELETE leaves no entry, so refetch will + // resolve to null and the section will return to the no-state + // posture. + await query.refetch(); + setIntentionallyOffline(false); + setNeverAutoRemediate(false); + notificationStore.success('Operator overrides cleared'); + } catch (err) { + notificationStore.error( + err instanceof Error ? err.message : 'Failed to clear operator overrides', + ); + } finally { + setSaving(false); + } + }; + + // Maintenance window is read-only this slice. Compute whether one is + // currently active so the section can badge it. + const activeMaintenanceWindow = createMemo(() => { + const current = persisted(); + if (!current?.maintenanceStartAt || !current?.maintenanceEndAt) return null; + const now = Date.now(); + const start = Date.parse(current.maintenanceStartAt); + const end = Date.parse(current.maintenanceEndAt); + if (Number.isNaN(start) || Number.isNaN(end)) return null; + if (now < start || now >= end) return null; + return current; + }); + + return ( +
+
+
+

Operator overrides

+

+ Tell Pulse how to treat this resource — suppress expected noise, or lock it against + automated remediation. +

+
+ + + + Set by {persisted()!.setBy} + + + {formatRelativeTime(persisted()!.setAt, { compact: true })} + + + +
+ + +
+ Maintenance window active.{' '} + Findings raised on this resource are auto-acknowledged until{' '} + {formatRelativeTime(activeMaintenanceWindow()!.maintenanceEndAt!, { compact: true })}. + + Reason: {activeMaintenanceWindow()!.maintenanceReason} + +
+
+ +
+
+ +

+ Suppress findings on this resource. Use when a workload is deprecated, a dev environment + is shut down on purpose, or a host is archived. +

+
+ setIntentionallyOffline(e.currentTarget.checked)} + disabled={saving()} + /> +
+ +
+
+ +

+ Refuse all automated remediation against this resource, even with a valid approval. The + action broker logs every refused dispatch as a Failed audit record. Use for resources + where Pulse must not act under any circumstance. +

+
+ handleNeverAutoRemediateToggle(e.currentTarget.checked)} + disabled={saving()} + /> +
+ + +
+

Lock this resource against all automated remediation?

+

+ Pulse will refuse every dispatch targeting this resource, including approved actions + from Patrol or the Assistant. Operators must clear the lock to allow remediation again. +

+
+ + +
+
+
+ +
+ + + + + + + +
+ +
+ ); +}; + +export default ResourceOperatorStateSection; diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index 4c5dedea2..ab9da9f2a 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -100,6 +100,16 @@ vi.mock('@/api/actionAudit', () => ({ }, })); +// Stub the operator-state client so the drawer's +// ResourceOperatorStateSection does not fan out a real network call +// during this test. Returns no-state (null) by default — the resource +// has no operator overrides, which is the default posture. +vi.mock('@/api/resourceOperatorState', () => ({ + getResourceOperatorState: vi.fn().mockResolvedValue(null), + setResourceOperatorState: vi.fn(), + clearResourceOperatorState: vi.fn(), +})); + class ResizeObserverMock { constructor(_callback: ResizeObserverCallback) {} observe() {} diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.test.ts b/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.test.ts new file mode 100644 index 000000000..4ba0b5dce --- /dev/null +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceOperatorStateSection.test.ts @@ -0,0 +1,103 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const sectionSource = readFileSync( + resolve(__dirname, '..', 'ResourceOperatorStateSection.tsx'), + 'utf-8', +); + +const overviewTabSource = readFileSync( + resolve(__dirname, '..', 'ResourceDetailDrawerOverviewTab.tsx'), + 'utf-8', +); + +describe('ResourceOperatorStateSection', () => { + it('exposes the two operator-set toggles bound to the canonical API client', () => { + // The section is the operator's window into the per-resource state + // feature. It must offer both toggles (intentionally offline + never + // auto-remediate) and route them through the canonical + // resourceOperatorState client — no parallel fetch path that could + // drift from the API contract. + expect(sectionSource).toContain("from '@/api/resourceOperatorState'"); + expect(sectionSource).toContain('getResourceOperatorState'); + expect(sectionSource).toContain('setResourceOperatorState'); + expect(sectionSource).toContain('clearResourceOperatorState'); + expect(sectionSource).toContain('Intentionally offline'); + expect(sectionSource).toContain('Never auto-remediate'); + }); + + it('keeps the section out of the parent Suspense fallback by using createNonSuspendingQuery', () => { + // The drawer wraps its children in a page-level Suspense fallback; + // a vanilla createResource here would flicker the fallback every + // time the section's fetch resolves. Pin the helper choice so this + // does not silently regress to createResource. + expect(sectionSource).toContain('createNonSuspendingQuery'); + expect(sectionSource).not.toContain('createResource<'); + }); + + it('requires explicit confirmation before flipping never-auto-remediate to true', () => { + // NeverAutoRemediate is a safety override — flipping it on must + // require explicit confirmation so the operator does not lock a + // resource by accident. The release path (true → false) is + // permissive because clearing a lock is the recoverable action. + expect(sectionSource).toContain('confirmingLock'); + expect(sectionSource).toContain('Lock this resource against all automated remediation?'); + expect(sectionSource).toContain('handleNeverAutoRemediateToggle'); + // The confirmation must not block the disable path — the inversion + // gate only fires when next=true and the current value is false. + expect(sectionSource).toContain('if (next && !neverAutoRemediate())'); + }); + + it('preserves persisted maintenance-window data on save so the toggle slice does not clobber the window slice', () => { + // This slice owns toggles only; the maintenance-window scheduler + // lands separately. If save sent only the toggle fields the server + // would null out the window data on every save (PUT replaces). Pin + // that the input passed to setResourceOperatorState carries the + // current window fields through. + expect(sectionSource).toContain('maintenanceStartAt: current?.maintenanceStartAt'); + expect(sectionSource).toContain('maintenanceEndAt: current?.maintenanceEndAt'); + expect(sectionSource).toContain('maintenanceReason: current?.maintenanceReason'); + expect(sectionSource).toContain('criticality: current?.criticality'); + expect(sectionSource).toContain('note: current?.note'); + }); + + it('renders a maintenance-window-active badge when the persisted window covers now', () => { + // Read-only display of the active window so operators see "this is + // why findings are quiet right now" without being able to schedule + // one yet (scheduler is a follow-up slice). The section must gate + // the badge on the now-falls-within-window check, not just on the + // presence of a window — a future-scheduled window should not show + // as active. + expect(sectionSource).toContain('activeMaintenanceWindow'); + expect(sectionSource).toContain('Maintenance window active.'); + expect(sectionSource).toContain('if (now < start || now >= end) return null;'); + }); + + it('attributes the persisted state with set-by and set-at metadata', () => { + // The audit-attribution comes from server-side population (setAt / + // setBy populated from the authenticated identity). The section + // must surface both so operators can see "I set this 3 days ago" + // when revisiting a resource. + expect(sectionSource).toContain('persisted()?.setBy'); + expect(sectionSource).toContain('persisted()?.setAt'); + }); +}); + +describe('ResourceDetailDrawerOverviewTab integration', () => { + it('renders ResourceOperatorStateSection alongside ResourceActionHistory', () => { + // The operator-set state and the action audit history are + // conceptually paired — what the operator decided to suppress, and + // what Pulse actually did. They belong on the same drawer surface + // so the operator can read both stories together. + expect(overviewTabSource).toContain("from './ResourceOperatorStateSection'"); + expect(overviewTabSource).toContain(''); + // Section must precede the action-history block so the override + // explains the actions that follow, not vice versa. + const operatorIndex = overviewTabSource.indexOf('