diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 813ccace4..d92793e1b 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -289,8 +289,9 @@ profile and assignment columns, but embedded table framing must route through agent command grant or lifecycle authorization primitive. Patrol queued-fix approvals may now seed the shared action-audit store with planned and pending lifecycle evidence, but lifecycle surfaces must treat - those records as action-governance context only; they do not create install, - enrollment, assignment, or fleet-command authority. + those records and their persisted requester identity as action-governance + context only; they do not create install, enrollment, assignment, or + fleet-command authority. Lifecycle flows must not reintroduce anonymous bootstrap identity, tenant-local commercial-owner surrogates, or fake activation records when they traverse those shared handlers. They also must not infer tenant diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 7126078de..d7f54a804 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -660,7 +660,10 @@ attach a governed action plan, seed the shared action-audit store as planned and pending with `pulse_patrol` as the requester/actor, and leave later execution or approval decisions to the governed action/approval paths instead of creating Patrol-only execution context or collapsing Patrol proposals into -generic Assistant-origin actions. +generic Assistant-origin actions. The approval record itself must also persist +and expose that requester identity so `/api/ai/approvals` and Assistant +handoffs preserve Patrol provenance before later action-audit hydration refreshes +the current action state. The same ownership includes the Pulse query tool schema under `internal/ai/tools/`: topology-query input names must stay canonical inside the AI runtime itself, so new tool arguments such as `max_proxmox_nodes` diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 8c9f09119..b2101fe54 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -439,7 +439,10 @@ the canonical monitored-system blocked payload. hydration. Queueing a Patrol investigation fix must stamp the action request and initial lifecycle events as `pulse_patrol`; it must not execute the fix, create a Patrol-local audit record that bypasses `/api/actions`, or present - Patrol-origin proposals as generic Assistant-origin actions. + Patrol-origin proposals as generic Assistant-origin actions. `/api/ai/approvals` + must expose the persisted `requestedBy` identity so frontend Assistant + handoffs can carry Patrol provenance before the chat runtime refreshes the + current action state from action audit. Action execution is API-owned as the next explicit contract: `POST /api/actions/{id}/execute` may only start execution for an approved action or an approval-free executable plan, must atomically persist the @@ -3157,8 +3160,9 @@ server-refreshed approval, resource, or action authority. Frontend Patrol handoff helpers may consume current pending approval list payloads only as safe metadata for that visible briefing and any structured `handoff_actions`: approval ID, status, risk, request/expiry - timestamps, target label, action ID, approval policy, plan expiry, and dry-run - summary are allowed. Assessment-level visible briefings may reuse that same + timestamps, target label, requester identity, action ID, approval policy, + plan expiry, and dry-run summary are allowed. Assessment-level visible + briefings may reuse that same safe metadata for action labels, safety notes, and approval-aware suggested prompts, while approval command text remains inside the governed approval/remediation surface. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 6a12d4a2a..7db6a7b9b 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -877,8 +877,9 @@ frontend primitive boundary. assessment handoff may add live pending approval posture only as safe structured metadata: approval ID, pending status, risk, target, requested/expiry timestamps, action plan identity, - approval policy, plan expiry, dry-run posture, and command count. Those - entries may be passed through shared chat transport as `handoff_actions` for + requester identity, approval policy, plan expiry, dry-run posture, and + command count. Those entries may be passed through shared chat transport as + `handoff_actions` for model-only refresh, but the shared drawer stays a generic shell rather than a Patrol summary prompt builder. The Patrol helper may turn those same safe references into visible action labels, safety notes, and approval-aware diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 0e37551dd..633cbb06c 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -854,7 +854,10 @@ Assistant handoffs and resource timelines can hydrate the same canonical action record before any operator decision or execution occurs. Those queued fix records must carry `pulse_patrol` as the requester and lifecycle actor so resource timelines preserve the product source of the proposal instead of -flattening proactive Patrol work into generic Assistant chat activity. +flattening proactive Patrol work into generic Assistant chat activity. Pending +approval payloads and Patrol Assistant handoff actions must carry the same +requester identity as safe metadata, without copying the approval command +payload into Assistant. That same store now owns the Patrol dashboard load bundle as well, so the page refresh path stays aligned on a single orchestrated AI bundle instead of repeating the individual summary, findings, approval, and correlation fetches diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 99169c5df..3a2dd020a 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1124,9 +1124,10 @@ freshness, restore authorization, storage remediation permission, or recovery transport state. Patrol queued-fix approvals that seed shared action-audit records follow the same rule: storage and recovery may display the resulting action history as -incident-adjacent context, but they must not treat the pending action state, -approval policy, or preflight posture as recovery proof or storage-local -execution permission. +incident-adjacent context, including the requester identity that distinguishes +Patrol-origin proposals from generic Assistant work, but they must not treat the +pending action state, approval policy, requester, or preflight posture as +recovery proof or storage-local execution permission. That same storage ownership also includes the shared storage-source presentation contract in `frontend-modern/src/utils/storageSources.ts`: storage pages and cross-surface storage links must reuse one canonical ordering, label, tone, and diff --git a/frontend-modern/src/api/__tests__/ai.test.ts b/frontend-modern/src/api/__tests__/ai.test.ts index bc9005f97..6269b167a 100644 --- a/frontend-modern/src/api/__tests__/ai.test.ts +++ b/frontend-modern/src/api/__tests__/ai.test.ts @@ -186,6 +186,24 @@ describe('AIAPI', () => { await expect(AIAPI.getPendingApprovals()).resolves.toEqual([]); }); + it('preserves approval requester identity from pending approvals', async () => { + apiFetchJSONMock.mockResolvedValueOnce({ + approvals: [ + { + id: 'approval-1', + requestedBy: 'pulse_patrol', + }, + ], + } as any); + + await expect(AIAPI.getPendingApprovals()).resolves.toEqual([ + expect.objectContaining({ + id: 'approval-1', + requestedBy: 'pulse_patrol', + }), + ]); + }); + it('normalizes remediation plans from optional or legacy collection payloads', async () => { apiFetchJSONMock.mockResolvedValueOnce({ plans: [{ id: 'plan-1' }] } as any); await expect(AIAPI.getRemediationPlans()).resolves.toEqual({ diff --git a/frontend-modern/src/api/ai.ts b/frontend-modern/src/api/ai.ts index d01e65b34..d67f5abf3 100644 --- a/frontend-modern/src/api/ai.ts +++ b/frontend-modern/src/api/ai.ts @@ -584,6 +584,7 @@ export interface ApprovalRequest { targetId: string; targetName: string; context: string; + requestedBy?: string; riskLevel: RiskLevel; status: ApprovalStatus; requestedAt: string; diff --git a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts index 3776c9d93..323c36de6 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolInvestigationContextModel.test.ts @@ -4,6 +4,7 @@ import type { PatrolRunRecord } from '@/api/patrol'; import { buildPatrolAssessmentAssistantHandoff, + buildPatrolAssistantApprovalBriefingInput, buildPatrolAssistantFindingBriefing, buildPatrolAssistantFindingHandoff, buildPatrolAssistantFindingHandoffActions, @@ -375,6 +376,7 @@ describe('patrolInvestigationContextModel', () => { actionPlanMessage: 'Restart after the backup window clears.', actionPreflight: 'Restart workload service', actionDryRunSummary: 'No provider-supported dry run is available for this action.', + actionRequestedBy: 'pulse_patrol', }, proposedFix: { description: 'Restart the workload service', @@ -396,6 +398,7 @@ describe('patrolInvestigationContextModel', () => { expect(handoff.context.handoffContext).toContain('high risk'); expect(handoff.context.handoffContext).toContain('approval target web-server'); expect(handoff.context.handoffContext).toContain('expires 2026-05-06T12:10:00Z'); + expect(handoff.context.handoffContext).toContain('requested by pulse_patrol'); expect(handoff.context.handoffContext).toContain('1 command recorded for approval context'); expect(handoff.context.handoffContext).toContain('destructive proposed fix'); expect(handoff.context.handoffActions).toHaveLength(1); @@ -406,6 +409,7 @@ describe('patrolInvestigationContextModel', () => { approvalRequestedAt: '2026-05-06T12:00:00Z', approvalExpiresAt: '2026-05-06T12:10:00Z', actionId: 'action-1', + actionRequestedBy: 'pulse_patrol', actionApprovalPolicy: 'admin', actionRequiresApproval: true, actionPlanExpiresAt: '2026-05-06T12:10:00Z', @@ -641,6 +645,35 @@ describe('patrolInvestigationContextModel', () => { expect(JSON.stringify(briefing)).not.toContain('systemctl restart workload.service'); }); + it('carries approval requester identity into safe Patrol handoff metadata', () => { + const briefing = buildPatrolAssistantApprovalBriefingInput({ + id: 'approval-1', + toolId: 'investigation_fix', + command: 'systemctl restart nginx', + targetType: 'investigation', + targetId: 'finding-1', + targetName: 'node-1', + context: 'Restart nginx after Patrol investigation', + requestedBy: 'pulse_patrol', + riskLevel: 'high', + status: 'pending', + requestedAt: '2026-05-06T12:00:00Z', + expiresAt: '2026-05-06T12:10:00Z', + plan: { + actionId: 'action-1', + approvalPolicy: 'admin', + }, + }); + + expect(briefing).toMatchObject({ + id: 'approval-1', + actionId: 'action-1', + actionApprovalPolicy: 'admin', + actionRequestedBy: 'pulse_patrol', + }); + expect(JSON.stringify(briefing)).not.toContain('systemctl restart nginx'); + }); + it('frames Assistant handoff around the structured Patrol record when one exists', () => { expect( buildPatrolAssistantFindingPrompt({ @@ -737,6 +770,7 @@ describe('patrolInvestigationContextModel', () => { actionPlanMessage: 'Restart nginx after validating load balancer drain.', actionPreflight: 'Would restart nginx on node-1.', actionDryRunSummary: 'One service restart would be attempted.', + actionRequestedBy: 'pulse_patrol', }, proposedFix: buildPatrolAssistantProposedFixBriefingInput({ description: 'Restart nginx', @@ -756,6 +790,7 @@ describe('patrolInvestigationContextModel', () => { approvalRequestedAt: '2026-05-06T12:00:00Z', approvalExpiresAt: '2026-05-06T12:10:00Z', actionId: 'restart-nginx', + actionRequestedBy: 'pulse_patrol', actionApprovalPolicy: 'operator', actionRequiresApproval: true, actionPlanExpiresAt: '2026-05-06T12:10:00Z', @@ -818,6 +853,7 @@ describe('patrolInvestigationContextModel', () => { actionPlanMessage: 'Restart nginx after validating load balancer drain.', actionPreflight: 'Would restart nginx on node-1.', actionDryRunSummary: 'One service restart would be attempted.', + actionRequestedBy: 'pulse_patrol', }, proposedFix: buildPatrolAssistantProposedFixBriefingInput({ description: 'Restart nginx', @@ -846,6 +882,7 @@ describe('patrolInvestigationContextModel', () => { }); expect(handoff.context.handoffContext).toContain('[Patrol Finding Context]'); expect(handoff.context.handoffContext).toContain('Approval: approval-1'); + expect(handoff.context.handoffContext).toContain('Action Requested By: pulse_patrol'); expect(handoff.context.handoffContext).toContain( 'Dry-Run Posture: One service restart would be attempted.', ); diff --git a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts index 6d9bd3a01..1b0f9c462 100644 --- a/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts +++ b/frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts @@ -95,6 +95,7 @@ export interface PatrolAssistantApprovalBriefingInput { actionPlanMessage?: string | null; actionPreflight?: string | null; actionDryRunSummary?: string | null; + actionRequestedBy?: string | null; } export interface PatrolAssistantProposedFixBriefingInput { @@ -414,6 +415,7 @@ export function buildPatrolAssistantApprovalBriefingInput( actionPlanMessage: normalizeText(approval.plan?.message || approval.plan?.summary), actionPreflight: normalizeText(approval.preflight?.intendedChange), actionDryRunSummary: normalizeText(approval.preflight?.dryRunSummary), + actionRequestedBy: normalizeText(approval.requestedBy), }; } @@ -1448,6 +1450,7 @@ function buildPatrolFindingHandoffAction( approvalRequestedAt: pendingApproval.requestedAt || undefined, approvalExpiresAt: pendingApproval.expiresAt || undefined, actionId: pendingApproval.actionId || undefined, + actionRequestedBy: pendingApproval.actionRequestedBy || undefined, actionApprovalPolicy: pendingApproval.actionApprovalPolicy || undefined, actionRequiresApproval: Boolean(approvalId), actionPlanExpiresAt: pendingApproval.actionPlanExpiresAt || undefined, @@ -1937,6 +1940,7 @@ function buildPatrolAssistantFindingModelContext( formatContextLine('Approval Requested At', pendingApproval.requestedAt), formatContextLine('Approval Expires At', pendingApproval.expiresAt), formatContextLine('Approval Policy', pendingApproval.actionApprovalPolicy), + formatContextLine('Action Requested By', pendingApproval.actionRequestedBy), formatContextLine('Approval Plan Expires At', pendingApproval.actionPlanExpiresAt), formatContextLine('Action Plan Summary', pendingApproval.actionPlanMessage), formatContextLine('Action Preflight', pendingApproval.actionPreflight), @@ -1978,6 +1982,9 @@ function formatAssessmentPendingApprovalContextParts( if (approval.requestedAt) { parts.push(`requested ${approval.requestedAt}`); } + if (approval.actionRequestedBy) { + parts.push(`requested by ${approval.actionRequestedBy}`); + } return parts; } @@ -2371,6 +2378,9 @@ function buildPatrolAssistantOperatorDecision( if (pendingApproval.requestedAt) { parts.push(`requested ${pendingApproval.requestedAt}`); } + if (pendingApproval.actionRequestedBy) { + parts.push(`requested by ${pendingApproval.actionRequestedBy}`); + } if (record.proposed_fix) { const fixId = normalizeText(record.proposed_fix.id); if (fixId) { @@ -2435,6 +2445,9 @@ function buildPatrolAssistantOperatorDecision( if (pendingApproval.requestedAt) { parts.push(`Requested: ${pendingApproval.requestedAt}.`); } + if (pendingApproval.actionRequestedBy) { + parts.push(`Requested by: ${pendingApproval.actionRequestedBy}.`); + } return parts.join(' '); } @@ -2559,6 +2572,7 @@ function normalizeApprovalBriefing( actionPlanMessage: normalizeText(approval?.actionPlanMessage), actionPreflight: normalizeText(approval?.actionPreflight), actionDryRunSummary: normalizeText(approval?.actionDryRunSummary), + actionRequestedBy: normalizeText(approval?.actionRequestedBy), }; } diff --git a/internal/ai/approval/store.go b/internal/ai/approval/store.go index 45c5cbaf4..96140085d 100644 --- a/internal/ai/approval/store.go +++ b/internal/ai/approval/store.go @@ -83,7 +83,8 @@ type ApprovalRequest struct { TargetType string `json:"targetType"` // agent, container, vm, node TargetID string `json:"targetId"` TargetName string `json:"targetName"` - Context string `json:"context"` // Why AI wants to run this + Context string `json:"context"` // Why AI wants to run this + RequestedBy string `json:"requestedBy,omitempty"` RiskLevel RiskLevel `json:"riskLevel"` // low, medium, high Status ApprovalStatus `json:"status"` RequestedAt time.Time `json:"requestedAt"` @@ -131,6 +132,9 @@ func RequesterForRequest(req *ApprovalRequest) string { if req == nil { return RequesterPulseAssistant } + if requester := strings.TrimSpace(req.RequestedBy); requester != "" { + return requester + } if strings.TrimSpace(req.ToolID) == "investigation_fix" || strings.TrimSpace(req.TargetType) == "investigation" { return RequesterPulsePatrol @@ -258,6 +262,7 @@ func (s *Store) CreateApproval(req *ApprovalRequest) error { if req.ExpiresAt.IsZero() { req.ExpiresAt = req.RequestedAt.Add(s.defaultTimeout) } + req.RequestedBy = RequesterForRequest(req) if req.Plan != nil { if strings.TrimSpace(req.Plan.ActionID) == "" { req.Plan.ActionID = uuid.New().String() diff --git a/internal/ai/approval/store_test.go b/internal/ai/approval/store_test.go index e4a0c34e0..2a7d165e5 100644 --- a/internal/ai/approval/store_test.go +++ b/internal/ai/approval/store_test.go @@ -287,6 +287,9 @@ func TestCreateApproval_UsesPatrolRequesterForInvestigationFixPreflight(t *testi if got := RequesterForRequest(req); got != RequesterPulsePatrol { t.Fatalf("RequesterForRequest = %q, want %q", got, RequesterPulsePatrol) } + if req.RequestedBy != RequesterPulsePatrol { + t.Fatalf("requestedBy = %q, want %q", req.RequestedBy, RequesterPulsePatrol) + } if req.Plan.Preflight == nil { t.Fatal("expected normalized action preflight") } @@ -298,6 +301,47 @@ func TestCreateApproval_UsesPatrolRequesterForInvestigationFixPreflight(t *testi } } +func TestCreateApproval_PreservesExplicitRequester(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "approval-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + store, _ := NewStore(StoreConfig{ + DataDir: tmpDir, + DefaultTimeout: 1 * time.Minute, + DisablePersistence: true, + }) + + req := &ApprovalRequest{ + ID: "approval-explicit-requester", + ToolID: "investigation_fix", + Command: "systemctl restart nginx", + TargetType: "investigation", + TargetID: "finding-123", + Context: "Restart nginx after Patrol investigation", + RequestedBy: "pulse_custom", + Plan: &unifiedresources.ActionPlan{ + ActionID: "action-explicit-requester", + Allowed: true, + RequiresApproval: true, + ApprovalPolicy: unifiedresources.ApprovalAdmin, + PlannedAt: time.Now().UTC(), + }, + } + + if err := store.CreateApproval(req); err != nil { + t.Fatalf("CreateApproval() error = %v", err) + } + if req.RequestedBy != "pulse_custom" { + t.Fatalf("requestedBy = %q, want pulse_custom", req.RequestedBy) + } + if got := RequesterForRequest(req); got != "pulse_custom" { + t.Fatalf("RequesterForRequest = %q, want pulse_custom", got) + } +} + func TestCreateApproval_RejectsUnsupportedHostTargetType(t *testing.T) { tmpDir, err := os.MkdirTemp("", "approval-test-*") if err != nil { diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index fcaa0c24d..3c20f48cd 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -2636,9 +2636,10 @@ func TestAISettingsHandler_Approvals(t *testing.T) { appID := "app-123" _ = approvalStore.CreateApproval(&approval.ApprovalRequest{ - ID: appID, - Command: "ls -la", - Status: approval.StatusPending, + ID: appID, + Command: "ls -la", + RequestedBy: approval.RequesterPulsePatrol, + Status: approval.StatusPending, }) t.Run("HandleGetApproval", func(t *testing.T) { @@ -2651,6 +2652,7 @@ func TestAISettingsHandler_Approvals(t *testing.T) { err := json.Unmarshal(rec.Body.Bytes(), &resp) require.NoError(t, err) assert.Equal(t, appID, resp.ID) + assert.Equal(t, approval.RequesterPulsePatrol, resp.RequestedBy) }) t.Run("HandleListApprovals", func(t *testing.T) { @@ -2667,6 +2669,7 @@ func TestAISettingsHandler_Approvals(t *testing.T) { require.NoError(t, err) require.Len(t, resp.Approvals, 1) assert.Equal(t, appID, resp.Approvals[0].ID) + assert.Equal(t, approval.RequesterPulsePatrol, resp.Approvals[0].RequestedBy) assert.Equal(t, 1, resp.Stats["pending"]) }) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index b9ea2ac47..7f87153ac 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -2951,6 +2951,7 @@ func TestContract_ApprovalJSONSnapshot(t *testing.T) { TargetID: "host-1", TargetName: "alpha", Context: "Cleanup temporary cache", + RequestedBy: approval.RequesterPulsePatrol, RiskLevel: approval.RiskHigh, Status: approval.StatusApproved, RequestedAt: now, @@ -2976,6 +2977,7 @@ func TestContract_ApprovalJSONSnapshot(t *testing.T) { "targetId":"host-1", "targetName":"alpha", "context":"Cleanup temporary cache", + "requestedBy":"pulse_patrol", "riskLevel":"high", "status":"approved", "requestedAt":"2026-02-08T13:14:15Z", diff --git a/internal/api/router_routes_ai_relay.go b/internal/api/router_routes_ai_relay.go index 8409f0ba4..e86c74f22 100644 --- a/internal/api/router_routes_ai_relay.go +++ b/internal/api/router_routes_ai_relay.go @@ -466,6 +466,7 @@ func (a *approvalStoreAdapter) CreateApproval(info *aicontracts.ApprovalInfo) er TargetID: info.TargetID, TargetName: info.TargetName, Context: info.Context, + RequestedBy: info.RequestedBy, RiskLevel: approval.RiskLevel(info.RiskLevel), Plan: approvalPlanInfoToRequest(info.Plan), ContextConfidence: contextConfidenceInfoToRequest(info.ContextConfidence), @@ -519,6 +520,7 @@ func approvalRequestToInfo(req *approval.ApprovalRequest) *aicontracts.ApprovalI TargetID: req.TargetID, TargetName: req.TargetName, Context: req.Context, + RequestedBy: req.RequestedBy, RiskLevel: string(req.RiskLevel), Status: string(req.Status), RequestedAt: req.RequestedAt, diff --git a/pkg/aicontracts/fix_execution.go b/pkg/aicontracts/fix_execution.go index ef57650e9..dadf8f42d 100644 --- a/pkg/aicontracts/fix_execution.go +++ b/pkg/aicontracts/fix_execution.go @@ -24,6 +24,7 @@ type ApprovalInfo struct { TargetID string `json:"targetId"` TargetName string `json:"targetName"` Context string `json:"context"` + RequestedBy string `json:"requestedBy,omitempty"` RiskLevel string `json:"riskLevel"` Status string `json:"status"` RequestedAt time.Time `json:"requestedAt"`