mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-19 16:27:37 +00:00
Add governed action audit preflight
This commit is contained in:
parent
175f8b4bf1
commit
acbed82d75
16 changed files with 626 additions and 79 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
|
|||
|
|
@ -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":[
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
89
internal/unifiedresources/actions_test.go
Normal file
89
internal/unifiedresources/actions_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ REPORT = {
|
|||
},
|
||||
},
|
||||
"summary": {
|
||||
"lane_count": 19,
|
||||
"lane_count": 20,
|
||||
},
|
||||
"coverage_gaps": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"]}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue