Add governed action audit preflight

This commit is contained in:
rcourtman 2026-04-25 22:35:19 +01:00
parent 175f8b4bf1
commit acbed82d75
16 changed files with 626 additions and 79 deletions

View file

@ -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",

View file

@ -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`

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 {

View file

@ -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 == "" {

View file

@ -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":[
{

View file

@ -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
}
}

View file

@ -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")
}
}

View file

@ -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)

View file

@ -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)

View file

@ -12,7 +12,7 @@ REPORT = {
},
},
"summary": {
"lane_count": 19,
"lane_count": 20,
},
"coverage_gaps": [
{

View file

@ -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"]}