diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 53676d3f1..9f1c7c810 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -3767,6 +3767,97 @@ "kind": "file" } ] + }, + { + "id": "L20", + "name": "Action governance and auditability", + "target_score": 6, + "current_score": 6, + "status": "partial", + "completion": { + "state": "bounded-residual", + "summary": "Action governance now has a first-class governed floor: tool capability declarations, approval-backed action plans, plan-level preflight and dry-run posture, fail-closed action-audit normalization, bounded lifecycle events, and API-visible action audit preflight are owned by the shared AI, API, security, Patrol, and unified-resource contracts. Broader enterprise policy execution, richer dry-run providers, multi-actor approvals, and surfaced action-history workflows remain a named post-RC hardening track.", + "tracking": [ + { + "kind": "lane-followup", + "id": "action-governance-auditability-post-rc-hardening" + } + ] + }, + "blockers": [], + "subsystems": [], + "evidence": [ + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/subsystems/ai-runtime.md", + "kind": "file" + }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/subsystems/api-contracts.md", + "kind": "file" + }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/subsystems/patrol-intelligence.md", + "kind": "file" + }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/subsystems/security-privacy.md", + "kind": "file" + }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/subsystems/unified-resources.md", + "kind": "file" + }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/V6_BRIDGE_RELEASE_FOUNDATION_SPEC.md", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/ai/approval/store.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/ai/approval/store_test.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/ai/tools/action_audit.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/api/contract_test.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/actions.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/actions_test.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/store.go", + "kind": "file" + }, + { + "repo": "pulse", + "path": "internal/unifiedresources/store_test.go", + "kind": "file" + } + ] } ], "release_gates": [ @@ -4463,6 +4554,17 @@ ], "subsystem_ids": [] }, + { + "id": "action-governance-auditability-post-rc-hardening", + "summary": "Track broader action-governance hardening beyond the current canonical action-audit floor, including richer provider dry-runs, enterprise policy execution, multi-actor or MFA approval chains, surfaced action-history workflows, and deeper safe-execution contracts for future controlled operations.", + "owner": "project-owner", + "status": "planned", + "recorded_at": "2026-04-25", + "lane_ids": [ + "L20" + ], + "subsystem_ids": [] + }, { "id": "policy-aware-data-governance-post-rc-hardening", "summary": "Track broader policy-aware data-governance hardening beyond the current resource-policy floor, including enterprise DLP posture, provider-retention disclosure, non-resource prompt-secret detection, and additional external-model boundary proof where future paid or hosted AI surfaces add new data paths.", @@ -4487,37 +4589,6 @@ } ], "coverage_gaps": [ - { - "id": "action-governance-and-audit", - "summary": "v6 has approvals and AI surfaces, but it lacks a governed action model covering declared capabilities, planning and dry-run boundaries, approval requirements, audit trail ownership, and safe execution contracts.", - "owner": "project-owner", - "status": "planned", - "recorded_at": "2026-03-17", - "lane_ids": [ - "L6", - "L14" - ], - "subsystem_ids": [ - "ai-runtime", - "api-contracts", - "patrol-intelligence", - "security-privacy" - ], - "proposed_resolution": "new-lane", - "coverage_impact": 14, - "evidence": [ - { - "repo": "pulse", - "path": "docs/release-control/v6/internal/subsystems/ai-runtime.md", - "kind": "file" - }, - { - "repo": "pulse", - "path": "docs/release-control/v6/internal/V6_BRIDGE_RELEASE_FOUNDATION_SPEC.md", - "kind": "file" - } - ] - }, { "id": "fleet-governance-v1", "summary": "L16 covers install and registration continuity, but the governed map still underrepresents fleet governance primitives such as enrollment state, version drift, adapter health, config rollout, credential status, and remote control-plane safety.", @@ -4651,27 +4722,6 @@ } ], "candidate_lanes": [ - { - "id": "action-governance-auditability", - "name": "Action governance and auditability", - "summary": "Promote action capability declaration, plan and dry-run state, approval requirements, safe execution boundaries, and audit ownership into a dedicated governed lane.", - "status": "planned", - "recorded_at": "2026-03-17", - "target_id": "v6-product-lane-expansion", - "current_lane_ids": [ - "L6", - "L14" - ], - "coverage_gap_ids": [ - "action-governance-and-audit" - ], - "subsystem_ids": [ - "ai-runtime", - "api-contracts", - "patrol-intelligence", - "security-privacy" - ] - }, { "id": "fleet-governance-rollout-control", "name": "Fleet governance and rollout control", diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index ab4b4c24a..51960119d 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -101,7 +101,10 @@ runtime cost control, and shared AI transport surfaces. `internal/ai/tools/executor.go` instead of maintaining hand-written prompt-only tool lists, and frontend approval cards must surface backend approval risk/description without hiding a pending approval when skip or - deny fails. + deny fails. Action-producing tools must also persist the unified + `ActionPlan.Preflight` dry-run boundary through + `internal/ai/tools/action_audit.go` rather than leaving dry-run availability + as chat-only text. 9. Keep self-hosted Patrol quickstart messaging aligned with backend runtime truth: the governed quickstart contract is Patrol-only first-run acceleration on installs with an activated entitlement and @@ -403,6 +406,12 @@ runtime must carry that approval identifier into the final `agentexec.ExecuteCommandPayload` so the host agent can re-check the shared command policy locally and fail closed on blocked or still-unapproved commands instead of treating control-plane approval as an implicit bypass. +The same action-audit boundary now also requires persisted action records to +carry a normalized plan and preflight: action id, request id, capability, +approval policy, dry-run availability, safety checks, verification steps, and +timestamps are normalized before persistence by the unified-resource store, so +runtime callers cannot publish an execution audit that skipped the canonical +planning contract. 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 a5d460cf3..ff1cf5cbe 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1553,6 +1553,10 @@ The unified action, lifecycle, and export audit reads now also clamp oversized `limit` requests to the governed maximum of `1000`, so the control-plane audit surface stays bounded even when callers ask for arbitrarily large history pages. +Unified action audit payloads must also expose the normalized action plan +preflight through `plan.preflight`: API consumers should see whether a dry-run +was available, what safety checks were recorded, and what verification steps +remain, instead of inferring action safety from free-form result text. Those relationship and timeline payloads now also carry `lastSeenAt` freshness and optional metadata through the same owned contract, so the drawer can preserve provenance without inventing a separate relationship-detail schema. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index b0c0e84ff..7ff4f8625 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -582,6 +582,10 @@ approval consumers must treat the approval queue as `soonest expiry first`, then higher risk, then older request time, rather than inheriting raw API order. Approval-linked findings must follow that same ordering so multi-approval `Review` actions jump to the most urgent finding instead of an arbitrary one. +Patrol fix approvals also inherit the unified action-governance preflight +contract: queued fixes must keep their plan-level dry-run availability, safety +checks, verification steps, approval policy, and action id in the shared +approval/action-audit model instead of storing Patrol-only execution context. 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/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 4bb804b9c..b1f81b78a 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -318,6 +318,11 @@ That same token-scope boundary also owns audit-log least privilege: audit event, verification, summary, export, and unified action/export audit reads must require the dedicated `audit:read` scope instead of inheriting broader monitoring or settings-read token access. +The same security boundary now depends on unified action-audit normalization: +persisted action records must identify the requester, resource, capability, +approval policy, preflight dry-run posture, and lifecycle state before they are +read through audit APIs, so audit history cannot silently accept an unscoped or +unplanned execution record. That same token-scope boundary now also governs Pulse Mobile relay runtime credentials: `internal/api/security_tokens.go` must mint only the dedicated backend-owned `relay:mobile:access` scope for new mobile relay tokens, and the diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 066b1ef92..d857ce2be 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -395,6 +395,13 @@ AI-only summary payloads, or page-local heuristics. ## Current State +`actions.go` now owns the canonical action preflight and audit-normalization +contract. Action plans must carry dry-run availability, safety checks, and +verification steps through `preflight`, and `RecordActionAudit` plus +`RecordActionLifecycleEvent` must normalize action id, request id, resource, +capability, approval policy, timestamps, lifecycle state, and requester before +records reach durable audit history. + `InfrastructureSummary.tsx` and `infrastructureSummaryModel.ts` now surface `degraded` and `alerting` resource counts alongside the existing `online` and `offline` totals. `buildInfrastructureResourceCounts` is the canonical owner of @@ -1872,10 +1879,10 @@ for provider-read breadcrumbs such as VMware tasks and events, plus the change model instead of introducing a second event shape, and `RecordChange` must stay idempotent by canonical change ID so poller refreshes and replayed supplemental snapshots do not duplicate resource history. -Action plans in `actions.go` now keep stale-plan protection to the canonical -`resourceVersion`, `policyVersion`, and `planHash` fields, so the durable -audit record stays minimal and does not need extra relationship-topology -versioning. +Action plans in `actions.go` still keep stale-plan protection to the canonical +`resourceVersion`, `policyVersion`, and `planHash` fields, so stale execution +checks stay in the shared resource action model rather than provider-local +helpers. The shared change presentation helper also owns the canonical kind, source type, and source adapter labels for those timeline entries, so summary cards and drawer history surfaces both read the same badge vocabulary instead of diff --git a/internal/ai/approval/store.go b/internal/ai/approval/store.go index 5489e053d..28bb2b3be 100644 --- a/internal/ai/approval/store.go +++ b/internal/ai/approval/store.go @@ -59,18 +59,9 @@ type ContextConfidence struct { Evidence []string `json:"evidence,omitempty"` } -// ActionPreflight is the deterministic pre-execution readout shown before -// approval. It is intentionally explicit when no provider dry-run exists. -type ActionPreflight struct { - Target string `json:"target,omitempty"` - CurrentState string `json:"currentState,omitempty"` - IntendedChange string `json:"intendedChange,omitempty"` - DryRunAvailable bool `json:"dryRunAvailable"` - DryRunSummary string `json:"dryRunSummary,omitempty"` - SafetyChecks []string `json:"safetyChecks,omitempty"` - VerificationSteps []string `json:"verificationSteps,omitempty"` - GeneratedAt time.Time `json:"generatedAt,omitempty"` -} +// ActionPreflight is the unified action-governance preflight readout re-exported +// here so existing approval API payloads keep their package-level type. +type ActionPreflight = unifiedresources.ActionPreflight // ApprovalRequest represents a pending command awaiting user approval. type ApprovalRequest struct { @@ -261,6 +252,20 @@ func (s *Store) CreateApproval(req *ApprovalRequest) error { if req.Plan.ApprovalPolicy == "" && req.Plan.RequiresApproval { req.Plan.ApprovalPolicy = unifiedresources.ApprovalAdmin } + if req.Plan.Preflight == nil && req.Preflight != nil { + req.Plan.Preflight = req.Preflight + } + if req.Preflight == nil && req.Plan.Preflight != nil { + req.Preflight = req.Plan.Preflight + } + req.Plan.Preflight = unifiedresources.NormalizeActionPreflight(req.Plan.Preflight, unifiedresources.ActionRequest{ + RequestID: req.Plan.RequestID, + ResourceID: approvalResourceID(req.TargetType, req.TargetID, req.TargetName), + CapabilityName: approvalCapabilityName(req.TargetType), + Reason: req.Context, + RequestedBy: "pulse_assistant", + }, *req.Plan) + req.Preflight = req.Plan.Preflight } // Assess risk if not set @@ -912,6 +917,34 @@ func isUnsupportedApprovalTargetType(targetType string) bool { return unifiedresources.IsUnsupportedLegacyResourceTypeAlias(normalizeApprovalTargetType(targetType)) } +func approvalResourceID(targetType, targetID, targetName string) string { + targetType = strings.TrimSpace(targetType) + targetID = strings.TrimSpace(targetID) + if targetID == "" { + targetID = strings.TrimSpace(targetName) + } + if targetID == "" { + return targetType + } + if targetType == "" || strings.Contains(targetID, ":") { + return targetID + } + return targetType + ":" + targetID +} + +func approvalCapabilityName(targetType string) string { + switch normalizeApprovalTargetType(targetType) { + case "docker": + return "pulse_docker" + case "file": + return "pulse_file_edit" + case "kubernetes": + return "pulse_kubernetes" + default: + return "pulse_control" + } +} + func canonicalizeApprovalRequest(req *ApprovalRequest) bool { if req == nil { return false diff --git a/internal/ai/approval/store_test.go b/internal/ai/approval/store_test.go index 217299101..d67a9d2d5 100644 --- a/internal/ai/approval/store_test.go +++ b/internal/ai/approval/store_test.go @@ -184,9 +184,15 @@ func TestCreateApproval_PreservesActionPlanAndContextConfidence(t *testing.T) { if got.Preflight == nil { t.Fatal("preflight was not preserved") } + if got.Plan.Preflight == nil { + t.Fatal("plan preflight was not populated from approval preflight") + } if got.Preflight.Target != "agent:web1 (agent-1)" { t.Fatalf("preflight target = %q, want agent:web1 (agent-1)", got.Preflight.Target) } + if got.Plan.Preflight.Target != got.Preflight.Target { + t.Fatalf("plan preflight target = %q, want %q", got.Plan.Preflight.Target, got.Preflight.Target) + } if got.Preflight.DryRunAvailable { t.Fatal("preflight dry run should remain false") } @@ -195,6 +201,57 @@ func TestCreateApproval_PreservesActionPlanAndContextConfidence(t *testing.T) { } } +func TestCreateApproval_PopulatesActionPlanPreflightWhenMissing(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: 5 * time.Minute, + }) + + req := &ApprovalRequest{ + ID: "approval-plan-preflight", + Command: "systemctl restart nginx", + TargetType: "agent", + TargetID: "agent-1", + TargetName: "web1", + Context: "Restart web service", + Plan: &unifiedresources.ActionPlan{ + ActionID: "action-plan-preflight", + Allowed: true, + RequiresApproval: true, + ApprovalPolicy: unifiedresources.ApprovalAdmin, + Message: "Restart web service", + PlanHash: "hash-plan-preflight", + }, + } + + if err := store.CreateApproval(req); err != nil { + t.Fatalf("CreateApproval() error = %v", err) + } + + got, ok := store.GetApproval("approval-plan-preflight") + if !ok { + t.Fatal("approval not found") + } + if got.Plan == nil || got.Plan.Preflight == nil { + t.Fatalf("expected plan preflight to be populated: %+v", got.Plan) + } + if got.Preflight != got.Plan.Preflight { + t.Fatal("approval preflight should share the normalized plan preflight") + } + if got.Plan.Preflight.Target != "agent:agent-1" { + t.Fatalf("preflight target = %q, want agent:agent-1", got.Plan.Preflight.Target) + } + if got.Plan.Preflight.DryRunAvailable { + t.Fatal("generated preflight should explicitly mark dry-run unavailable") + } +} + func TestCreateApproval_RejectsUnsupportedHostTargetType(t *testing.T) { tmpDir, err := os.MkdirTemp("", "approval-test-*") if err != nil { diff --git a/internal/ai/tools/action_audit.go b/internal/ai/tools/action_audit.go index e3f0530c4..d97a299fe 100644 --- a/internal/ai/tools/action_audit.go +++ b/internal/ai/tools/action_audit.go @@ -67,6 +67,7 @@ func (e *PulseToolExecutor) executeCommandWithAudit( PolicyVersion: "", PlanHash: actionPlanHash(actionID, requestCorrelationID, capabilityName, resourceID, payload, reason), Message: reason, + Preflight: actionAuditPreflight(resourceID, reason, now), } if planFromApproval { plan = mergeApprovedActionPlan(*approvalReq.Plan, plan) @@ -184,6 +185,7 @@ func (e *PulseToolExecutor) executeNativeActionWithAudit( PolicyVersion: "", PlanHash: actionPlanHashForParams(actionID, requestCorrelationID, capabilityName, resourceID, params, reason), Message: reason, + Preflight: actionAuditPreflight(resourceID, reason, now), } if planFromApproval { plan = mergeApprovedActionPlan(*approvalReq.Plan, plan) @@ -477,6 +479,18 @@ func cloneActionParams(params map[string]any) map[string]any { return cloned } +func actionAuditPreflight(resourceID, reason string, generatedAt time.Time) *unifiedresources.ActionPreflight { + return unifiedresources.NormalizeActionPreflight(&unifiedresources.ActionPreflight{ + Target: strings.TrimSpace(resourceID), + IntendedChange: strings.TrimSpace(reason), + DryRunAvailable: false, + DryRunSummary: "No provider-supported dry run is available for this action.", + SafetyChecks: []string{"Approval and execution are scoped to the resolved resource."}, + VerificationSteps: []string{"Read back the target state after execution."}, + GeneratedAt: generatedAt, + }, unifiedresources.ActionRequest{ResourceID: resourceID, Reason: reason}, unifiedresources.ActionPlan{PlannedAt: generatedAt, Message: reason}) +} + func approvalRecordsForID(approvalID string) []unifiedresources.ActionApprovalRecord { approvalID = strings.TrimSpace(approvalID) if approvalID == "" { diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 1910bd696..b0f0fcf37 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -11428,6 +11428,16 @@ func TestContract_UnifiedActionAuditsJSONSnapshot(t *testing.T) { ResourceVersion: "rv-1", PolicyVersion: "pv-1", PlanHash: "hash-1", + Preflight: &unifiedresources.ActionPreflight{ + Target: "vm:42", + CurrentState: "online", + IntendedChange: "restart", + DryRunAvailable: false, + DryRunSummary: "No provider-supported dry run is available for this action.", + SafetyChecks: []string{"Approval and execution are scoped to vm:42."}, + VerificationSteps: []string{"Read back VM status after execution."}, + GeneratedAt: now, + }, }, Approvals: []unifiedresources.ActionApprovalRecord{ { @@ -11478,7 +11488,17 @@ func TestContract_UnifiedActionAuditsJSONSnapshot(t *testing.T) { "expiresAt":"2026-03-18T16:05:00Z", "resourceVersion":"rv-1", "policyVersion":"pv-1", - "planHash":"hash-1" + "planHash":"hash-1", + "preflight":{ + "target":"vm:42", + "currentState":"online", + "intendedChange":"restart", + "dryRunAvailable":false, + "dryRunSummary":"No provider-supported dry run is available for this action.", + "safetyChecks":["Approval and execution are scoped to vm:42."], + "verificationSteps":["Read back VM status after execution."], + "generatedAt":"2026-03-18T16:00:00Z" + } }, "approvals":[ { diff --git a/internal/unifiedresources/actions.go b/internal/unifiedresources/actions.go index 23d5c07d7..51d4237cb 100644 --- a/internal/unifiedresources/actions.go +++ b/internal/unifiedresources/actions.go @@ -1,6 +1,10 @@ package unifiedresources -import "time" +import ( + "fmt" + "strings" + "time" +) // ActionState tracks the lifecycle of bounded capability execution. type ActionState string @@ -51,6 +55,21 @@ type ActionApprovalRecord struct { Reason string `json:"reason,omitempty"` } +// ActionPreflight is the deterministic pre-execution readout shown before an +// action is approved or executed. It is intentionally explicit when no provider +// dry-run exists, so action audits can distinguish "not available" from +// "not recorded". +type ActionPreflight struct { + Target string `json:"target,omitempty"` + CurrentState string `json:"currentState,omitempty"` + IntendedChange string `json:"intendedChange,omitempty"` + DryRunAvailable bool `json:"dryRunAvailable"` + DryRunSummary string `json:"dryRunSummary,omitempty"` + SafetyChecks []string `json:"safetyChecks,omitempty"` + VerificationSteps []string `json:"verificationSteps,omitempty"` + GeneratedAt time.Time `json:"generatedAt,omitempty"` +} + // ActionPlan is the deterministic response Pulse gives back before execution. type ActionPlan struct { ActionID string `json:"actionId"` // Internal durable identity @@ -63,11 +82,12 @@ type ActionPlan struct { Message string `json:"message,omitempty"` // Stale-plan protection - PlannedAt time.Time `json:"plannedAt"` - ExpiresAt time.Time `json:"expiresAt"` - ResourceVersion string `json:"resourceVersion"` // Hash of the resource state at planning time - PolicyVersion string `json:"policyVersion"` // Version of the capability/policy when planned - PlanHash string `json:"planHash"` // Hash verifying params and resource state haven't drifted + PlannedAt time.Time `json:"plannedAt"` + ExpiresAt time.Time `json:"expiresAt"` + ResourceVersion string `json:"resourceVersion"` // Hash of the resource state at planning time + PolicyVersion string `json:"policyVersion"` // Version of the capability/policy when planned + PlanHash string `json:"planHash"` // Hash verifying params and resource state haven't drifted + Preflight *ActionPreflight `json:"preflight,omitempty"` } // ExecutionResult captures the output of the native capability driver. @@ -104,3 +124,173 @@ type ActionEngine interface { ApproveAction(actionID string, approval ActionApprovalRecord) error ExecuteAction(actionID string) (*ExecutionResult, error) } + +// NormalizeActionAuditRecord applies the canonical action-governance floor +// before a record is persisted. It keeps older callers usable by filling safe +// deterministic defaults, but rejects records that cannot identify the action, +// state, resource, capability, or requester. +func NormalizeActionAuditRecord(record ActionAuditRecord) (ActionAuditRecord, error) { + record.ID = strings.TrimSpace(record.ID) + record.Plan.ActionID = strings.TrimSpace(record.Plan.ActionID) + if record.ID == "" { + record.ID = record.Plan.ActionID + } + if record.ID == "" { + return ActionAuditRecord{}, fmt.Errorf("action audit id required") + } + if record.Plan.ActionID == "" { + record.Plan.ActionID = record.ID + } + if record.Plan.ActionID != record.ID { + return ActionAuditRecord{}, fmt.Errorf("action audit id %q does not match plan action id %q", record.ID, record.Plan.ActionID) + } + if !isValidActionState(record.State) { + return ActionAuditRecord{}, fmt.Errorf("unsupported action state %q", record.State) + } + + record.Request.RequestID = strings.TrimSpace(record.Request.RequestID) + record.Plan.RequestID = strings.TrimSpace(record.Plan.RequestID) + if record.Request.RequestID == "" { + record.Request.RequestID = record.Plan.RequestID + } + if record.Request.RequestID == "" { + record.Request.RequestID = record.ID + } + if record.Plan.RequestID == "" { + record.Plan.RequestID = record.Request.RequestID + } + if record.Plan.RequestID != record.Request.RequestID { + return ActionAuditRecord{}, fmt.Errorf("action request id %q does not match plan request id %q", record.Request.RequestID, record.Plan.RequestID) + } + + record.Request.ResourceID = CanonicalResourceID(record.Request.ResourceID) + record.Request.CapabilityName = strings.TrimSpace(record.Request.CapabilityName) + record.Request.Reason = strings.TrimSpace(record.Request.Reason) + record.Request.RequestedBy = strings.TrimSpace(record.Request.RequestedBy) + if record.Request.ResourceID == "" { + return ActionAuditRecord{}, fmt.Errorf("action request resource id required") + } + if record.Request.CapabilityName == "" { + return ActionAuditRecord{}, fmt.Errorf("action request capability name required") + } + if record.Request.RequestedBy == "" { + return ActionAuditRecord{}, fmt.Errorf("action request requestedBy required") + } + + if record.CreatedAt.IsZero() { + record.CreatedAt = time.Now().UTC() + } else { + record.CreatedAt = record.CreatedAt.UTC() + } + if record.UpdatedAt.IsZero() { + record.UpdatedAt = record.CreatedAt + } else { + record.UpdatedAt = record.UpdatedAt.UTC() + } + + if record.Plan.PlannedAt.IsZero() { + record.Plan.PlannedAt = record.CreatedAt + } else { + record.Plan.PlannedAt = record.Plan.PlannedAt.UTC() + } + if record.Plan.ExpiresAt.IsZero() { + record.Plan.ExpiresAt = record.Plan.PlannedAt.Add(5 * time.Minute) + } else { + record.Plan.ExpiresAt = record.Plan.ExpiresAt.UTC() + } + if record.Plan.ApprovalPolicy == "" { + if record.Plan.RequiresApproval { + record.Plan.ApprovalPolicy = ApprovalAdmin + } else { + record.Plan.ApprovalPolicy = ApprovalNone + } + } + record.Plan.Preflight = NormalizeActionPreflight(record.Plan.Preflight, record.Request, record.Plan) + + for i := range record.Approvals { + record.Approvals[i].Actor = strings.TrimSpace(record.Approvals[i].Actor) + record.Approvals[i].Reason = strings.TrimSpace(record.Approvals[i].Reason) + if record.Approvals[i].Method == "" { + record.Approvals[i].Method = MethodAPI + } + if record.Approvals[i].Outcome == "" { + record.Approvals[i].Outcome = OutcomeApproved + } + if record.Approvals[i].Timestamp.IsZero() { + record.Approvals[i].Timestamp = record.UpdatedAt + } else { + record.Approvals[i].Timestamp = record.Approvals[i].Timestamp.UTC() + } + } + + return record, nil +} + +// NormalizeActionLifecycleEvent applies the same action-governance identity and +// state checks to append-only lifecycle events. +func NormalizeActionLifecycleEvent(event ActionLifecycleEvent) (ActionLifecycleEvent, error) { + event.ActionID = strings.TrimSpace(event.ActionID) + if event.ActionID == "" { + return ActionLifecycleEvent{}, fmt.Errorf("action lifecycle event action id required") + } + if !isValidActionState(event.State) { + return ActionLifecycleEvent{}, fmt.Errorf("unsupported action lifecycle state %q", event.State) + } + if event.Timestamp.IsZero() { + event.Timestamp = time.Now().UTC() + } else { + event.Timestamp = event.Timestamp.UTC() + } + event.Actor = strings.TrimSpace(event.Actor) + event.Message = strings.TrimSpace(event.Message) + return event, nil +} + +// NormalizeActionPreflight ensures persisted action plans always state whether +// a dry-run was available and what post-execution verification should inspect. +func NormalizeActionPreflight(preflight *ActionPreflight, request ActionRequest, plan ActionPlan) *ActionPreflight { + if preflight == nil { + preflight = &ActionPreflight{} + } + preflight.Target = strings.TrimSpace(preflight.Target) + if preflight.Target == "" { + preflight.Target = request.ResourceID + } + preflight.CurrentState = strings.TrimSpace(preflight.CurrentState) + preflight.IntendedChange = strings.TrimSpace(preflight.IntendedChange) + if preflight.IntendedChange == "" { + preflight.IntendedChange = strings.TrimSpace(plan.Message) + } + if preflight.IntendedChange == "" { + preflight.IntendedChange = strings.TrimSpace(request.Reason) + } + preflight.DryRunSummary = strings.TrimSpace(preflight.DryRunSummary) + if preflight.DryRunSummary == "" { + if preflight.DryRunAvailable { + preflight.DryRunSummary = "Provider-supported dry run is available for this action." + } else { + preflight.DryRunSummary = "No provider-supported dry run is available for this action." + } + } + if len(preflight.SafetyChecks) == 0 { + preflight.SafetyChecks = []string{"Action is recorded in the unified action audit."} + } + if len(preflight.VerificationSteps) == 0 { + preflight.VerificationSteps = []string{"Review the action result and lifecycle events after execution."} + } + if preflight.GeneratedAt.IsZero() { + preflight.GeneratedAt = plan.PlannedAt + } else { + preflight.GeneratedAt = preflight.GeneratedAt.UTC() + } + return preflight +} + +func isValidActionState(state ActionState) bool { + switch state { + case ActionStatePlanned, ActionStatePending, ActionStateApproved, ActionStateRejected, ActionStateExecuting, ActionStateCompleted, ActionStateFailed: + return true + default: + return false + } +} diff --git a/internal/unifiedresources/actions_test.go b/internal/unifiedresources/actions_test.go new file mode 100644 index 000000000..9ba7730cf --- /dev/null +++ b/internal/unifiedresources/actions_test.go @@ -0,0 +1,89 @@ +package unifiedresources + +import ( + "strings" + "testing" + "time" +) + +func TestNormalizeActionAuditRecordPopulatesGovernedPlanPreflight(t *testing.T) { + now := time.Date(2026, 4, 25, 22, 30, 0, 0, time.UTC) + record, err := NormalizeActionAuditRecord(ActionAuditRecord{ + ID: " action-1 ", + CreatedAt: now, + State: ActionStateExecuting, + Request: ActionRequest{ + ResourceID: " vm:42 ", + CapabilityName: " pulse_control ", + Reason: "Restart workload", + RequestedBy: " pulse_assistant ", + }, + }) + if err != nil { + t.Fatalf("NormalizeActionAuditRecord() error = %v", err) + } + + if record.ID != "action-1" || record.Plan.ActionID != "action-1" { + t.Fatalf("action id normalization failed: %#v", record) + } + if record.Request.RequestID != "action-1" || record.Plan.RequestID != "action-1" { + t.Fatalf("request id normalization failed: request=%q plan=%q", record.Request.RequestID, record.Plan.RequestID) + } + if record.Request.ResourceID != "vm:42" || record.Request.CapabilityName != "pulse_control" || record.Request.RequestedBy != "pulse_assistant" { + t.Fatalf("request normalization failed: %#v", record.Request) + } + if record.Plan.ApprovalPolicy != ApprovalNone { + t.Fatalf("approval policy = %q, want %q", record.Plan.ApprovalPolicy, ApprovalNone) + } + if record.Plan.Preflight == nil { + t.Fatal("expected preflight to be populated") + } + if record.Plan.Preflight.Target != "vm:42" { + t.Fatalf("preflight target = %q, want vm:42", record.Plan.Preflight.Target) + } + if record.Plan.Preflight.DryRunAvailable { + t.Fatal("default preflight must explicitly mark dry-run unavailable") + } + if !strings.Contains(record.Plan.Preflight.DryRunSummary, "No provider-supported dry run") { + t.Fatalf("unexpected dry-run summary: %q", record.Plan.Preflight.DryRunSummary) + } + if len(record.Plan.Preflight.SafetyChecks) == 0 || len(record.Plan.Preflight.VerificationSteps) == 0 { + t.Fatalf("preflight should carry safety and verification checks: %#v", record.Plan.Preflight) + } +} + +func TestNormalizeActionAuditRecordRejectsUngovernedRecords(t *testing.T) { + _, err := NormalizeActionAuditRecord(ActionAuditRecord{ + ID: "action-1", + State: ActionStateCompleted, + Request: ActionRequest{ + ResourceID: "vm:42", + RequestedBy: "pulse_assistant", + }, + }) + if err == nil { + t.Fatal("expected missing capability to be rejected") + } + + _, err = NormalizeActionAuditRecord(ActionAuditRecord{ + ID: "action-1", + State: ActionState("unknown"), + Request: ActionRequest{ + ResourceID: "vm:42", + CapabilityName: "pulse_control", + RequestedBy: "pulse_assistant", + }, + }) + if err == nil { + t.Fatal("expected invalid state to be rejected") + } +} + +func TestNormalizeActionLifecycleEventRejectsInvalidEvents(t *testing.T) { + if _, err := NormalizeActionLifecycleEvent(ActionLifecycleEvent{State: ActionStatePlanned}); err == nil { + t.Fatal("expected missing action id to be rejected") + } + if _, err := NormalizeActionLifecycleEvent(ActionLifecycleEvent{ActionID: "action-1", State: ActionState("paused")}); err == nil { + t.Fatal("expected invalid lifecycle state to be rejected") + } +} diff --git a/internal/unifiedresources/store.go b/internal/unifiedresources/store.go index c0dd3c3d7..02a9ab1fd 100644 --- a/internal/unifiedresources/store.go +++ b/internal/unifiedresources/store.go @@ -900,6 +900,12 @@ func (s *SQLiteResourceStore) CountRecentChangesBySourceAdapterFiltered(canonica } func (s *SQLiteResourceStore) RecordActionAudit(record ActionAuditRecord) error { + normalized, err := NormalizeActionAuditRecord(record) + if err != nil { + return err + } + record = normalized + s.mu.Lock() defer s.mu.Unlock() @@ -1015,10 +1021,16 @@ func (s *SQLiteResourceStore) GetActionAudits(canonicalID string, since time.Tim } func (s *SQLiteResourceStore) RecordActionLifecycleEvent(event ActionLifecycleEvent) error { + normalized, err := NormalizeActionLifecycleEvent(event) + if err != nil { + return err + } + event = normalized + s.mu.Lock() defer s.mu.Unlock() - _, err := s.db.Exec(` + _, err = s.db.Exec(` INSERT INTO action_lifecycle_events (action_id, timestamp, state, actor, message) VALUES (?, ?, ?, ?, ?) `, event.ActionID, event.Timestamp, string(event.State), event.Actor, event.Message) @@ -1403,6 +1415,12 @@ func changeMatchesResource(change ResourceChange, canonicalID string, includeRel } func (m *MemoryStore) RecordActionAudit(record ActionAuditRecord) error { + normalized, err := NormalizeActionAuditRecord(record) + if err != nil { + return err + } + record = normalized + m.mu.Lock() defer m.mu.Unlock() for i := range m.actionAudits { @@ -1437,6 +1455,12 @@ func (m *MemoryStore) GetActionAudits(canonicalID string, since time.Time, limit } func (m *MemoryStore) RecordActionLifecycleEvent(event ActionLifecycleEvent) error { + normalized, err := NormalizeActionLifecycleEvent(event) + if err != nil { + return err + } + event = normalized + m.mu.Lock() defer m.mu.Unlock() m.actionLifecycleEvents = append(m.actionLifecycleEvents, event) diff --git a/internal/unifiedresources/store_test.go b/internal/unifiedresources/store_test.go index c184d59cd..828e711c6 100644 --- a/internal/unifiedresources/store_test.go +++ b/internal/unifiedresources/store_test.go @@ -1318,6 +1318,47 @@ func TestMemoryStore_RecordActionAudit_UpsertsByID(t *testing.T) { } } +func TestRecordActionAudit_NormalizesGovernedPlan(t *testing.T) { + store := newTestStore(t) + now := time.Date(2026, 4, 25, 22, 40, 0, 0, time.UTC) + record := ActionAuditRecord{ + ID: "action-governed-1", + CreatedAt: now, + UpdatedAt: now, + State: ActionStateExecuting, + Request: ActionRequest{ + ResourceID: " vm:500 ", + CapabilityName: "pulse_control", + Reason: "restart vm", + RequestedBy: "pulse_assistant", + }, + } + + if err := store.RecordActionAudit(record); err != nil { + t.Fatalf("RecordActionAudit: %v", err) + } + results, err := store.GetActionAudits("vm:500", now.Add(-time.Hour), 10) + if err != nil { + t.Fatalf("GetActionAudits: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 normalized action audit, got %d", len(results)) + } + got := results[0] + if got.Request.RequestID != "action-governed-1" || got.Plan.ActionID != "action-governed-1" || got.Plan.RequestID != "action-governed-1" { + t.Fatalf("normalized action identity = %#v", got) + } + if got.Request.ResourceID != "vm:500" { + t.Fatalf("normalized resource id = %q, want vm:500", got.Request.ResourceID) + } + if got.Plan.ApprovalPolicy != ApprovalNone { + t.Fatalf("approval policy = %q, want %q", got.Plan.ApprovalPolicy, ApprovalNone) + } + if got.Plan.Preflight == nil || got.Plan.Preflight.Target != "vm:500" || got.Plan.Preflight.DryRunAvailable { + t.Fatalf("normalized preflight = %#v", got.Plan.Preflight) + } +} + func TestSQLiteStore_GetActionAudits_AllWhenResourceIDBlank(t *testing.T) { store := newTestStore(t) now := time.Date(2026, 3, 18, 13, 45, 0, 0, time.UTC) diff --git a/scripts/release_control/status_lookup_test.py b/scripts/release_control/status_lookup_test.py index 682bde962..2694e6dd3 100644 --- a/scripts/release_control/status_lookup_test.py +++ b/scripts/release_control/status_lookup_test.py @@ -12,7 +12,7 @@ REPORT = { }, }, "summary": { - "lane_count": 19, + "lane_count": 20, }, "coverage_gaps": [ { diff --git a/scripts/release_control/subsystem_lookup_test.py b/scripts/release_control/subsystem_lookup_test.py index 1cf14e46b..03487a23a 100644 --- a/scripts/release_control/subsystem_lookup_test.py +++ b/scripts/release_control/subsystem_lookup_test.py @@ -84,7 +84,7 @@ class SubsystemLookupTest(unittest.TestCase): {"v6-rc-cut", "v6-rc-stabilization", "v6-ga-promotion", "v6-product-lane-expansion"}, ) self.assertEqual(result["scope"]["control_plane_repo"], "pulse") - self.assertEqual(result["status_summary"]["lane_count"], 19) + self.assertEqual(result["status_summary"]["lane_count"], 20) file_entry = result["files"][0] matches = {match["subsystem"] for match in file_entry["matches"]}