Files
pulse/internal/unifiedresources/actions.go
T
2026-07-12 00:18:43 +01:00

1423 lines
60 KiB
Go

package unifiedresources
import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
// ActionState tracks the lifecycle of bounded capability execution.
type ActionState string
const (
ActionStatePlanned ActionState = "planned"
ActionStatePending ActionState = "pending_approval"
ActionStateApproved ActionState = "approved"
ActionStateRejected ActionState = "rejected"
ActionStateExpired ActionState = "expired"
ActionStateExecuting ActionState = "executing"
ActionStateCompleted ActionState = "completed"
ActionStateFailed ActionState = "failed"
)
// ActionRequest is the payload from an agent or human requesting a capability execution.
type ActionRequest struct {
RequestID string `json:"requestId"` // Caller idempotency key / external correlation
ResourceID string `json:"resourceId"`
CapabilityName string `json:"capabilityName"`
Params map[string]any `json:"params,omitempty"`
Reason string `json:"reason"`
// RequestedBy remains on the wire for compatibility and presentation, but
// canonical planning derives it from Actor.SubjectID. Public callers never
// own this value.
RequestedBy string `json:"requestedBy"`
Actor ActionActor `json:"actor"`
}
// ActionActor is the immutable server-owned identity bound to a governed
// action. SubjectID is the durable principal; CredentialID identifies the
// authenticated credential without persisting a secret.
type ActionActor struct {
SubjectID string `json:"subjectId"`
Kind ActionActorKind `json:"kind"`
CredentialID string `json:"credentialId"`
OrgID string `json:"orgId"`
}
type ActionActorKind string
const (
ActionActorUser ActionActorKind = "user"
ActionActorAPIToken ActionActorKind = "api_token"
ActionActorService ActionActorKind = "service"
ActionActorPolicy ActionActorKind = "policy"
)
func NormalizeActionActor(actor ActionActor) ActionActor {
actor.SubjectID = strings.TrimSpace(actor.SubjectID)
actor.CredentialID = strings.TrimSpace(actor.CredentialID)
actor.OrgID = strings.TrimSpace(actor.OrgID)
return actor
}
func ValidateActionActor(actor ActionActor) error {
actor = NormalizeActionActor(actor)
if actor.SubjectID == "" || actor.CredentialID == "" || actor.OrgID == "" {
return errors.New("action actor subject, credential, and organization are required")
}
switch actor.Kind {
case ActionActorUser, ActionActorAPIToken, ActionActorService, ActionActorPolicy:
return nil
default:
return fmt.Errorf("unsupported action actor kind %q", actor.Kind)
}
}
func ActionActorsEqual(left, right ActionActor) bool {
left = NormalizeActionActor(left)
right = NormalizeActionActor(right)
return left == right
}
const ActionApprovalRequirementVersion = 1
// ApprovalRequirement is the capability-owned approval floor captured at
// planning time. Tenant policy may strengthen this structure in a future
// resolver, but it must never lower Floor.
type ApprovalRequirement struct {
Version int `json:"version"`
Floor ActionApprovalLevel `json:"floor"`
Quorum int `json:"quorum"`
DisallowRequester bool `json:"disallowRequester"`
}
func ApprovalRequirementForFloor(floor ActionApprovalLevel) ApprovalRequirement {
return ApprovalRequirement{
Version: ActionApprovalRequirementVersion,
Floor: floor,
Quorum: 1,
}
}
func NormalizeApprovalRequirement(requirement ApprovalRequirement, legacyFloor ActionApprovalLevel) ApprovalRequirement {
if requirement.Floor == "" {
requirement.Floor = legacyFloor
}
if requirement.Quorum < 1 {
requirement.Quorum = 1
}
return requirement
}
func ValidateApprovalRequirement(requirement ApprovalRequirement, capabilityFloor ActionApprovalLevel) error {
requirement = NormalizeApprovalRequirement(requirement, capabilityFloor)
if requirement.Version != ActionApprovalRequirementVersion || requirement.Quorum < 1 {
return errors.New("approval requirement version and quorum are invalid")
}
if capabilityFloor == ApprovalDryRun {
if requirement.Floor != ApprovalDryRun {
return errors.New("dry-run-only capability floor cannot be lowered")
}
return nil
}
if requirement.Floor == ApprovalDryRun {
return nil
}
rank := func(level ActionApprovalLevel) int {
switch level {
case ApprovalNone:
return 0
case ApprovalAdmin:
return 1
case ApprovalMultiFactor:
return 2
default:
return -1
}
}
if rank(requirement.Floor) < rank(capabilityFloor) || rank(requirement.Floor) < 0 {
return errors.New("approval requirement cannot lower the capability floor")
}
return nil
}
// ApprovalOutcome represents the decision on a requested capability.
type ApprovalOutcome string
const (
OutcomeApproved ApprovalOutcome = "approved"
OutcomeRejected ApprovalOutcome = "rejected"
)
// ApprovalMethod tracks how the decision was collected.
type ApprovalMethod string
const (
MethodUI ApprovalMethod = "ui"
MethodAPI ApprovalMethod = "api"
MethodMFAChallenge ApprovalMethod = "mfa_challenge"
MethodPolicy ApprovalMethod = "policy"
MethodSession ApprovalMethod = "session"
MethodAPIToken ApprovalMethod = "api_token"
MethodWebAuthnUV ApprovalMethod = "webauthn_uv"
MethodDeviceKeyUV ApprovalMethod = "device_key_uv"
)
// ApprovalEvidence is the server-checked decision binding. Cryptographic
// methods are never trusted from this structure alone: actionlifecycle calls
// its installed verifier to validate and atomically consume the challenge.
type ApprovalEvidence struct {
Version int `json:"version"`
Method ApprovalMethod `json:"method"`
Actor ActionActor `json:"actor"`
OrgID string `json:"orgId"`
ActionID string `json:"actionId"`
PlanHash string `json:"planHash"`
Outcome ApprovalOutcome `json:"outcome"`
ChallengeID string `json:"challengeId,omitempty"`
IssuedAt time.Time `json:"issuedAt"`
ExpiresAt time.Time `json:"expiresAt,omitempty"`
}
// ActionDecision is the transport-independent input to the canonical human
// decision boundary. Actor and Evidence are supplied by a trusted adapter,
// never decoded as public identity authority.
type ActionDecision struct {
Actor ActionActor `json:"actor"`
Outcome ApprovalOutcome `json:"outcome"`
Reason string `json:"reason,omitempty"`
Evidence ApprovalEvidence `json:"evidence"`
}
// ActionApprovalRecord captures a specific approval or rejection event.
type ActionApprovalRecord struct {
Actor string `json:"actor"` // Who approved/rejected it
Method ApprovalMethod `json:"method"` // e.g. "ui", "api", "mfa_challenge"
Timestamp time.Time `json:"timestamp"` // When the decision was made
Outcome ApprovalOutcome `json:"outcome"` // "approved" or "rejected"
Reason string `json:"reason,omitempty"`
ActorBinding ActionActor `json:"actorBinding,omitempty"`
Evidence *ApprovalEvidence `json:"evidence,omitempty"`
// PolicyLease is present only for a server-owned policy authorization.
// Human decisions never carry this field. Policy approval and execution
// admission are persisted atomically, so the lease cannot become reusable.
PolicyLease *ActionPolicyAuthorizationLease `json:"policyLease,omitempty"`
}
// ActionPolicyAuthorizationLease binds automatic authorization to every
// policy input that was current at the executing transition.
type ActionPolicyAuthorizationLease struct {
Version int `json:"version"`
OrgID string `json:"orgId"`
ActionID string `json:"actionId"`
ResourceID string `json:"resourceId"`
CapabilityName string `json:"capabilityName"`
PlanHash string `json:"planHash"`
CapabilityPolicyVersion string `json:"capabilityPolicyVersion"`
AutoAuthorization ActionAutoAuthorizationClass `json:"autoAuthorization"`
ApprovalPolicy ActionApprovalLevel `json:"approvalPolicy"`
TenantPolicyVersion string `json:"tenantPolicyVersion"`
EffectiveAutonomyLevel string `json:"effectiveAutonomyLevel"`
LicenseAllowsAutoFix bool `json:"licenseAllowsAutoFix"`
FullModeUnlocked bool `json:"fullModeUnlocked"`
EmergencyStop bool `json:"emergencyStop"`
ResourcePolicyVersion string `json:"resourcePolicyVersion"`
CapabilityNames []string `json:"capabilityNames"`
Window *AutoRemediationWindow `json:"window,omitempty"`
NeverAutoRemediate bool `json:"neverAutoRemediate"`
IssuedAt time.Time `json:"issuedAt"`
ExpiresAt time.Time `json:"expiresAt"`
Digest string `json:"digest"`
}
// ActionPolicyAuthorizationDigest returns the canonical SHA-256 binding for
// a lease. Digest itself is excluded from the encoded payload.
func ActionPolicyAuthorizationDigest(lease ActionPolicyAuthorizationLease) string {
lease.Digest = ""
payload, _ := json.Marshal(lease)
sum := sha256.Sum256(payload)
return fmt.Sprintf("sha256:%x", sum[:])
}
// ValidateActionPolicyAuthorizationLease fails closed on incomplete,
// malformed, expired, or tampered automatic authority.
func ValidateActionPolicyAuthorizationLease(lease ActionPolicyAuthorizationLease, record ActionAuditRecord, now time.Time) error {
if lease.Version != 1 || strings.TrimSpace(lease.OrgID) == "" ||
lease.ActionID != record.ID || lease.ResourceID != CanonicalResourceID(record.Request.ResourceID) ||
lease.CapabilityName != strings.TrimSpace(record.Request.CapabilityName) ||
lease.PlanHash != record.Plan.PlanHash || lease.CapabilityPolicyVersion != record.Plan.PolicyVersion ||
strings.TrimSpace(lease.TenantPolicyVersion) == "" || strings.TrimSpace(lease.ResourcePolicyVersion) == "" {
return ErrActionPolicyAuthorizationInvalid
}
if lease.EmergencyStop || lease.NeverAutoRemediate || !lease.LicenseAllowsAutoFix {
return ErrActionPolicyAuthorizationRevoked
}
if lease.IssuedAt.IsZero() || lease.ExpiresAt.IsZero() || !now.Before(lease.ExpiresAt) {
return ErrActionPolicyAuthorizationExpired
}
if lease.Digest == "" || lease.Digest != ActionPolicyAuthorizationDigest(lease) {
return ErrActionPolicyAuthorizationInvalid
}
return nil
}
// 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
RequestID string `json:"requestId"`
Allowed bool `json:"allowed"`
RequiresApproval bool `json:"requiresApproval"`
ApprovalPolicy ActionApprovalLevel `json:"approvalPolicy"`
ApprovalRequirement ApprovalRequirement `json:"approvalRequirement"`
PredictedBlastRadius []string `json:"predictedBlastRadius,omitempty"` // Correlated related resources
RollbackAvailable bool `json:"rollbackAvailable"`
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
PolicyDecision ActionPolicyDecisionProvenance `json:"policyDecision"`
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.
type ExecutionResult struct {
Success bool `json:"success"`
Output string `json:"output,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
Verification *ActionVerificationResult `json:"verification,omitempty"`
ActionResultV2 *ActionResultV2 `json:"actionResultV2,omitempty"`
}
// ActionVerificationResult records the outcome of a post-execution
// read-after-write check. The broker derives a class-specific verification
// command (e.g. `systemctl is-active <unit>` after a service-restart action)
// and runs it via the same agent path used for the original dispatch. The
// result is persisted alongside ExecutionResult so the audit history shows
// not only what the action did but whether the read-back confirmed the
// intended state. Verification is best-effort: if the agent is unreachable
// or no verification command is derivable for the action class, Ran is
// false and the rest of the fields are empty rather than fabricated.
type ActionVerificationResult struct {
Ran bool `json:"ran"`
Command string `json:"command,omitempty"`
Output string `json:"output,omitempty"`
Success bool `json:"success"`
RanAt time.Time `json:"ranAt,omitempty"`
Note string `json:"note,omitempty"`
}
// VerificationStatus classifies the post-execution read-after-write outcome
// for an action.
//
// The progression is read as: did Pulse confirm the intended state after the
// write?
// - VerificationUnknown: no verification has been attempted yet, or the
// record predates the verifier substrate. Default for older audits on
// read so the contract surface stays additive.
// - VerificationVerified: a postcondition was evaluated and matched.
// - VerificationUnverified: a postcondition was attempted but could not
// conclude (e.g. agent unreachable inside the window). Distinct from
// failure so operators are not misled into thinking the action broke.
// - VerificationFailed: a postcondition was evaluated and did not
// match - the write claims to have succeeded but the world disagrees.
type VerificationStatus string
const (
VerificationUnknown VerificationStatus = "unknown"
VerificationVerified VerificationStatus = "verified"
VerificationUnverified VerificationStatus = "unverified"
VerificationFailed VerificationStatus = "failed"
)
// VerificationOutcome is the durable verification projection persisted on
// the action audit record. EvidenceSummary carries an optional one-line
// human-readable note about what evidence (or its absence) drove the
// status - e.g. "systemctl ActiveState=active observed within 12s".
type VerificationOutcome struct {
Status VerificationStatus `json:"status"`
EvidenceSummary string `json:"evidenceSummary,omitempty"`
}
// IsValidVerificationStatus reports whether the given status is one of the
// closed enum values.
func IsValidVerificationStatus(status VerificationStatus) bool {
switch status {
case VerificationUnknown, VerificationVerified, VerificationUnverified, VerificationFailed:
return true
default:
return false
}
}
// NormalizeVerificationOutcome coerces empty or unrecognized statuses to
// VerificationUnknown so older audit records read without breaking the
// closed enum contract, and trims EvidenceSummary.
func NormalizeVerificationOutcome(outcome VerificationOutcome) VerificationOutcome {
outcome.EvidenceSummary = strings.TrimSpace(outcome.EvidenceSummary)
if outcome.Status == "" || !IsValidVerificationStatus(outcome.Status) {
outcome.Status = VerificationUnknown
}
return outcome
}
// VerificationOutcomeFromExecutionResult derives the durable lifecycle
// classification from the executor's read-after-write result at the single
// completion boundary. Executors report evidence; they do not get to write a
// separate audit classification that can drift from it.
func VerificationOutcomeFromExecutionResult(result *ExecutionResult) VerificationOutcome {
if result == nil || !result.Success || result.Verification == nil {
return VerificationOutcome{Status: VerificationUnknown}
}
note := strings.TrimSpace(result.Verification.Note)
verification := NormalizeActionVerificationResult(result.Verification)
if verification == nil || !verification.Ran {
return VerificationOutcome{Status: VerificationUnverified, EvidenceSummary: note}
}
if verification.Success {
return VerificationOutcome{Status: VerificationVerified, EvidenceSummary: strings.TrimSpace(verification.Note)}
}
return VerificationOutcome{Status: VerificationFailed, EvidenceSummary: strings.TrimSpace(verification.Note)}
}
// NormalizeActionVerificationResult applies the canonical verification field
// hygiene used by stored audit records and every action-audit projection.
func NormalizeActionVerificationResult(result *ActionVerificationResult) *ActionVerificationResult {
if result == nil {
return nil
}
normalized := *result
normalized.Command = strings.TrimSpace(normalized.Command)
normalized.Output = strings.TrimSpace(normalized.Output)
normalized.Note = strings.TrimSpace(normalized.Note)
if normalized.RanAt.IsZero() {
normalized.RanAt = time.Time{}
} else {
normalized.RanAt = normalized.RanAt.UTC()
}
if !normalized.Ran {
normalized.Command = ""
normalized.Output = ""
normalized.Note = ""
normalized.Success = false
normalized.RanAt = time.Time{}
}
return &normalized
}
func cloneActionVerificationResult(result *ActionVerificationResult) *ActionVerificationResult {
if result == nil {
return nil
}
clone := *result
return &clone
}
// CanonicalActionVerification returns the top-level verification projection for
// an audit record, falling back to legacy result.verification records while
// older persisted rows are still being read.
func CanonicalActionVerification(record ActionAuditRecord) *ActionVerificationResult {
if record.Result != nil && record.Result.ActionResultV2 != nil {
canonical := CanonicalActionResultV2(record)
projected := LegacyActionVerificationFromV2(canonical)
if projected == nil || !projected.Ran {
return NormalizeActionVerificationResult(projected)
}
for _, candidate := range []*ActionVerificationResult{record.Verification, record.Result.Verification} {
compatibility := NormalizeActionVerificationResult(candidate)
if compatibility != nil && compatibility.Ran && compatibility.Success == projected.Success {
return compatibility
}
}
return NormalizeActionVerificationResult(projected)
}
if result := NormalizeActionVerificationResult(record.Verification); result != nil {
return result
}
if record.Result == nil {
return nil
}
return NormalizeActionVerificationResult(record.Result.Verification)
}
// ActionAuditRecord tracks the full end-to-end lifecycle of a tool invocation.
type ActionAuditRecord struct {
ID string `json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
State ActionState `json:"state"`
DecisionRevision uint64 `json:"decisionRevision"`
Request ActionRequest `json:"request"`
Plan ActionPlan `json:"plan"`
Origin *ActionOrigin `json:"origin,omitempty"`
Approvals []ActionApprovalRecord `json:"approvals,omitempty"`
Result *ExecutionResult `json:"result,omitempty"`
Verification *ActionVerificationResult `json:"verification,omitempty"`
VerificationOutcome VerificationOutcome `json:"verificationOutcome"`
}
// ActionOrigin records which internal surface proposed the action and the
// correlation identifiers that surface needs to reconcile decisions and
// terminal outcomes back onto its own records (e.g. a Patrol finding).
// Origin is broker-owned metadata: it is set by in-process planning callers
// only and is never accepted from the public plan request body.
type ActionOrigin struct {
Surface string `json:"surface"`
FindingID string `json:"findingId,omitempty"`
InvestigationID string `json:"investigationId,omitempty"`
ProposalID string `json:"proposalId,omitempty"`
}
// NormalizeActionOrigin trims origin fields and collapses an all-empty
// origin to nil so absent metadata never persists as an empty object.
func NormalizeActionOrigin(origin *ActionOrigin) *ActionOrigin {
if origin == nil {
return nil
}
normalized := ActionOrigin{
Surface: strings.TrimSpace(origin.Surface),
FindingID: strings.TrimSpace(origin.FindingID),
InvestigationID: strings.TrimSpace(origin.InvestigationID),
ProposalID: strings.TrimSpace(origin.ProposalID),
}
if normalized == (ActionOrigin{}) {
return nil
}
return &normalized
}
type ActionLifecycleEventKind string
const (
ActionLifecycleEventTransition ActionLifecycleEventKind = "transition"
ActionLifecycleEventDecision ActionLifecycleEventKind = "decision"
ActionLifecycleEventLegacy ActionLifecycleEventKind = "legacy"
)
// ActionLifecycleEvent is an append-only action audit fact. State transitions
// remain unique by action/state; human decisions are independently unique by
// action/decision revision and carry their complete server-bound approval.
type ActionLifecycleEvent struct {
ActionID string `json:"actionId"`
Timestamp time.Time `json:"timestamp"`
State ActionState `json:"state"`
Kind ActionLifecycleEventKind `json:"kind"`
DecisionRevision uint64 `json:"decisionRevision,omitempty"`
Decision *ActionApprovalRecord `json:"decision,omitempty"`
Actor string `json:"actor,omitempty"`
Message string `json:"message,omitempty"`
}
// ActionEngine defines the enforced runtime loop for capabilities.
type ActionEngine interface {
PlanAction(req ActionRequest) (*ActionPlan, error)
ApproveAction(actionID string, approval ActionApprovalRecord) error
ExecuteAction(actionID string) (*ExecutionResult, error)
}
var (
ErrActionNotPending = errors.New("action is not pending approval")
ErrActionNotApproved = errors.New("action is not approved for execution")
// ErrActionPlanDrift is returned when the payload presented at execute
// time does not match the plan hash recorded at approval time. The hash
// is the contract for "the operator approved exactly this action"; if
// it does not match, the broker must refuse execution rather than run a
// drifted plan under a stale approval.
ErrActionPlanDrift = errors.New("approved plan hash does not match execution payload; refusing to run drifted plan")
// ErrResourceRemediationLocked is returned when the operator has set
// NeverAutoRemediate=true on the target resource. The action broker
// must refuse the dispatch even when the approval ID resolves and
// the plan hash matches; the operator's per-resource intent
// outranks the per-action approval. Persists a Failed audit
// record with `resource_remediation_locked:` prefix on the
// ErrorMessage so the audit timeline shows every refused dispatch.
ErrResourceRemediationLocked = errors.New("resource is operator-locked against automated remediation")
ErrActionNotExecuting = errors.New("action is not executing")
ErrActionAlreadyExecuting = errors.New("action is already executing")
ErrActionExecutionFinal = errors.New("action execution is already final")
ErrActionPlanExpired = errors.New("action plan expired")
ErrActionPlanNotExpired = errors.New("action plan has not expired")
ErrActionDryRunOnly = errors.New("action plan is dry-run only")
ErrActionExecutionRefusal = errors.New("action execution refusal is not a permanent terminal refusal")
ErrInvalidApprovalOutcome = errors.New("invalid approval outcome")
ErrActionAuditAlreadyExists = errors.New("action audit already exists")
ErrActionIdentityConflict = errors.New("action audit identity conflicts with the persisted record")
ErrActionDecisionRevisionConflict = errors.New("action decision revision conflicts with the persisted record")
ErrActionPolicyAuthorizationInvalid = errors.New("policy_authorization_invalid")
ErrActionPolicyAuthorizationExpired = errors.New("policy_authorization_expired")
ErrActionPolicyAuthorizationRevoked = errors.New("policy_authorization_revoked")
ErrActionEmergencyStop = errors.New("action_emergency_stop")
ErrActionReplanRequired = errors.New("action_replan_required")
ErrDuplicateApprovalActor = errors.New("duplicate approval actor")
)
// BeginPolicyActionExecution atomically composes the policy approval and
// executing record. Stores persist both lifecycle events in one CAS write.
func BeginPolicyActionExecution(record ActionAuditRecord, approval ActionApprovalRecord, lease ActionPolicyAuthorizationLease, now time.Time) (ActionAuditRecord, ActionLifecycleEvent, ActionLifecycleEvent, error) {
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
if record.State != ActionStatePending && record.State != ActionStatePlanned {
return ActionAuditRecord{}, ActionLifecycleEvent{}, ActionLifecycleEvent{}, ErrActionNotApproved
}
if err := ValidateActionExecutionStart(record, now); err != nil && !errors.Is(err, ErrActionNotApproved) {
return ActionAuditRecord{}, ActionLifecycleEvent{}, ActionLifecycleEvent{}, err
}
if err := ValidateActionPolicyAuthorizationLease(lease, record, now); err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, ActionLifecycleEvent{}, err
}
approval.Actor = strings.TrimSpace(approval.Actor)
approval.Method = MethodPolicy
approval.Outcome = OutcomeApproved
approval.Timestamp = now
approval.PolicyLease = &lease
record.Approvals = append(record.Approvals, approval)
record.State = ActionStateExecuting
record.UpdatedAt = now
normalized, err := NormalizeActionAuditRecord(record)
if err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, ActionLifecycleEvent{}, err
}
approvedEvent := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now, State: ActionStateApproved, Actor: approval.Actor, Message: "Action authorized by current Patrol policy."}
startedEvent := ActionLifecycleEvent{ActionID: record.ID, Timestamp: now, State: ActionStateExecuting, Actor: approval.Actor, Message: "Policy-authorized action execution started."}
return normalized, approvedEvent, startedEvent, nil
}
// ActionAuditIdentityMatches reports whether a replay addresses the same
// immutable governed action. Lifecycle state, timestamps, approvals, results,
// and verification are deliberately excluded because the persisted record is
// authoritative for those mutable fields.
func ActionAuditIdentityMatches(existing, replay ActionAuditRecord) bool {
existing, existingErr := NormalizeActionAuditRecord(existing)
replay, replayErr := NormalizeActionAuditRecord(replay)
if existingErr != nil || replayErr != nil {
return false
}
return existing.ID == replay.ID &&
existing.Plan.ActionID == replay.Plan.ActionID &&
existing.Plan.PlanHash == replay.Plan.PlanHash &&
canonicalActionIdentityJSONEqual(existing.Request, replay.Request) &&
canonicalActionIdentityJSONEqual(existing.Origin, replay.Origin)
}
func canonicalActionIdentityJSONEqual(left, right any) bool {
leftJSON, leftErr := json.Marshal(left)
rightJSON, rightErr := json.Marshal(right)
return leftErr == nil && rightErr == nil && bytes.Equal(leftJSON, rightJSON)
}
// ApplyActionDecision records an explicit approval or rejection against a
// pending governed action without starting execution. Execution remains a
// separate contract so approvals cannot become an implicit control bypass.
func ApplyActionDecision(record ActionAuditRecord, approval ActionApprovalRecord, now time.Time) (ActionAuditRecord, ActionLifecycleEvent, error) {
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
approval.ActorBinding = NormalizeActionActor(approval.ActorBinding)
if approval.ActorBinding.SubjectID != "" {
approval.Actor = approval.ActorBinding.SubjectID
}
approval.Actor = strings.TrimSpace(approval.Actor)
approval.Reason = strings.TrimSpace(approval.Reason)
if approval.Method == "" {
approval.Method = MethodAPI
}
if approval.Timestamp.IsZero() {
approval.Timestamp = now
} else {
approval.Timestamp = approval.Timestamp.UTC()
}
for _, existing := range record.Approvals {
if approval.Actor != "" && strings.EqualFold(strings.TrimSpace(existing.Actor), approval.Actor) {
return ActionAuditRecord{}, ActionLifecycleEvent{}, ErrDuplicateApprovalActor
}
}
var nextState ActionState
var message string
switch approval.Outcome {
case OutcomeApproved:
requirement := NormalizeApprovalRequirement(record.Plan.ApprovalRequirement, record.Plan.ApprovalPolicy)
approvedActors := map[string]struct{}{}
for _, existing := range record.Approvals {
if existing.Outcome == OutcomeApproved && strings.TrimSpace(existing.Actor) != "" {
approvedActors[strings.ToLower(strings.TrimSpace(existing.Actor))] = struct{}{}
}
}
approvedActors[strings.ToLower(approval.Actor)] = struct{}{}
if len(approvedActors) < requirement.Quorum {
nextState = ActionStatePending
message = fmt.Sprintf("Approval recorded; %d of %d distinct approvals collected.", len(approvedActors), requirement.Quorum)
} else {
nextState = ActionStateApproved
message = "Action approved. Execution remains pending a separate execution contract."
}
case OutcomeRejected:
nextState = ActionStateRejected
message = "Action rejected before execution."
default:
return ActionAuditRecord{}, ActionLifecycleEvent{}, ErrInvalidApprovalOutcome
}
if record.State != ActionStatePending {
return ActionAuditRecord{}, ActionLifecycleEvent{}, ErrActionNotPending
}
if !record.Plan.ExpiresAt.IsZero() && !now.Before(record.Plan.ExpiresAt) {
return ActionAuditRecord{}, ActionLifecycleEvent{}, ErrActionPlanExpired
}
record.State = nextState
record.UpdatedAt = approval.Timestamp
record.Approvals = append(record.Approvals, approval)
record.DecisionRevision++
normalized, err := NormalizeActionAuditRecord(record)
if err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, err
}
event := ActionLifecycleEvent{
ActionID: normalized.ID,
Timestamp: approval.Timestamp,
State: nextState,
Kind: ActionLifecycleEventDecision,
DecisionRevision: normalized.DecisionRevision,
Decision: &approval,
Actor: approval.Actor,
Message: message,
}
normalizedEvent, err := NormalizeActionLifecycleEvent(event)
if err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, err
}
return normalized, normalizedEvent, nil
}
// ActionDecisionAppendCommand is the canonical pure store command for one
// append-only human decision. Both persistence implementations validate and
// execute this same command before applying their atomic CAS mechanics.
type ActionDecisionAppendCommand struct {
Record ActionAuditRecord
DecisionEvent ActionLifecycleEvent
TransitionEvent *ActionLifecycleEvent
}
// PrepareActionDecisionAppend validates an append against the authoritative
// current audit and derives the optional lifecycle transition. It validates
// captured evidence structure and policy-floor compatibility, but deliberately
// does not perform cryptographic verification or current membership/RBAC.
func PrepareActionDecisionAppend(current, proposed ActionAuditRecord, event ActionLifecycleEvent) (ActionDecisionAppendCommand, error) {
current, err := NormalizeActionAuditRecord(current)
if err != nil {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
proposed, err = NormalizeActionAuditRecord(proposed)
if err != nil {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
event, err = NormalizeActionLifecycleEvent(event)
if err != nil {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
if current.State != ActionStatePending || !ActionAuditIdentityMatches(current, proposed) ||
current.DecisionRevision != uint64(len(current.Approvals)) ||
proposed.DecisionRevision != current.DecisionRevision+1 ||
len(proposed.Approvals) != len(current.Approvals)+1 ||
proposed.DecisionRevision != uint64(len(proposed.Approvals)) || event.Decision == nil ||
event.ActionID != proposed.ID || event.State != proposed.State || event.DecisionRevision != proposed.DecisionRevision {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
for index := range current.Approvals {
if !canonicalActionIdentityJSONEqual(current.Approvals[index], proposed.Approvals[index]) {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
}
if err := ValidateActionActor(proposed.Request.Actor); err != nil {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
requirement := NormalizeApprovalRequirement(proposed.Plan.ApprovalRequirement, proposed.Plan.ApprovalPolicy)
if err := ValidateApprovalRequirement(requirement, proposed.Plan.ApprovalPolicy); err != nil {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
approvedActors := map[string]struct{}{}
seenSubjects := map[string]struct{}{}
for index := range proposed.Approvals {
approval := proposed.Approvals[index]
approval.ActorBinding = NormalizeActionActor(approval.ActorBinding)
if err := ValidateActionActor(approval.ActorBinding); err != nil ||
(approval.ActorBinding.Kind != ActionActorUser && approval.ActorBinding.Kind != ActionActorAPIToken) ||
approval.Actor != approval.ActorBinding.SubjectID || approval.Evidence == nil || approval.Timestamp.IsZero() {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
subjectKey := strings.ToLower(approval.ActorBinding.SubjectID)
if _, duplicate := seenSubjects[subjectKey]; duplicate {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
seenSubjects[subjectKey] = struct{}{}
if requirement.DisallowRequester && strings.EqualFold(approval.ActorBinding.SubjectID, proposed.Request.Actor.SubjectID) {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
evidence := *approval.Evidence
evidence.Actor = NormalizeActionActor(evidence.Actor)
if evidence.Version != 1 || evidence.IssuedAt.IsZero() ||
!ActionActorsEqual(evidence.Actor, approval.ActorBinding) || evidence.OrgID != proposed.Request.Actor.OrgID ||
evidence.ActionID != proposed.ID || evidence.PlanHash != proposed.Plan.PlanHash ||
evidence.Outcome != approval.Outcome || evidence.Method != approval.Method ||
evidence.IssuedAt.After(approval.Timestamp) ||
(!evidence.ExpiresAt.IsZero() && (evidence.ExpiresAt.Before(evidence.IssuedAt) || approval.Timestamp.After(evidence.ExpiresAt))) {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
switch approval.Method {
case MethodSession:
if approval.ActorBinding.Kind != ActionActorUser || strings.TrimSpace(evidence.ChallengeID) != "" {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
case MethodAPIToken:
if approval.ActorBinding.Kind != ActionActorAPIToken || strings.TrimSpace(evidence.ChallengeID) != "" {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
case MethodWebAuthnUV, MethodDeviceKeyUV:
if approval.ActorBinding.Kind != ActionActorUser || strings.TrimSpace(evidence.ChallengeID) == "" || evidence.ExpiresAt.IsZero() || !evidence.ExpiresAt.After(evidence.IssuedAt) {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
default:
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
if approval.Outcome == OutcomeApproved {
switch requirement.Floor {
case ApprovalDryRun:
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
case ApprovalMultiFactor:
if approval.Method != MethodWebAuthnUV && approval.Method != MethodDeviceKeyUV {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
case ApprovalAdmin, ApprovalNone:
if approval.Method != MethodSession && approval.Method != MethodAPIToken {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
default:
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
}
if index < len(proposed.Approvals)-1 && approval.Outcome != OutcomeApproved {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
if approval.Outcome == OutcomeApproved {
approvedActors[subjectKey] = struct{}{}
}
}
last := proposed.Approvals[len(proposed.Approvals)-1]
if !canonicalActionIdentityJSONEqual(last, *event.Decision) || event.Actor != last.Actor || !event.Timestamp.Equal(last.Timestamp) {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
derivedState := ActionStateRejected
expectedMessage := "Action rejected before execution."
if last.Outcome == OutcomeApproved {
if len(approvedActors) < requirement.Quorum {
derivedState = ActionStatePending
expectedMessage = fmt.Sprintf("Approval recorded; %d of %d distinct approvals collected.", len(approvedActors), requirement.Quorum)
} else {
derivedState = ActionStateApproved
expectedMessage = "Action approved. Execution remains pending a separate execution contract."
}
} else if last.Outcome != OutcomeRejected {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
if proposed.State != derivedState || event.State != derivedState || event.Message != expectedMessage {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
command := ActionDecisionAppendCommand{Record: proposed, DecisionEvent: event}
if derivedState != ActionStatePending {
transition := event
transition.Kind = ActionLifecycleEventTransition
transition.DecisionRevision = 0
transition.Decision = nil
transition, err = NormalizeActionLifecycleEvent(transition)
if err != nil {
return ActionDecisionAppendCommand{}, ErrActionDecisionRevisionConflict
}
command.TransitionEvent = &transition
}
return command, nil
}
// ExpireAction materializes plan expiry as an explicit monotonic lifecycle
// state. Expiry is admission truth, not an execution-result classification.
func ExpireAction(record ActionAuditRecord, actor string, now time.Time) (ActionAuditRecord, ActionLifecycleEvent, error) {
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
if record.State == ActionStateExpired {
return record, ActionLifecycleEvent{}, nil
}
switch record.State {
case ActionStatePlanned, ActionStatePending, ActionStateApproved:
default:
return ActionAuditRecord{}, ActionLifecycleEvent{}, ErrActionExecutionFinal
}
if record.Plan.ExpiresAt.IsZero() || now.Before(record.Plan.ExpiresAt) {
return ActionAuditRecord{}, ActionLifecycleEvent{}, ErrActionPlanNotExpired
}
record.State = ActionStateExpired
record.UpdatedAt = now
normalized, err := NormalizeActionAuditRecord(record)
if err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, err
}
event, err := NormalizeActionLifecycleEvent(ActionLifecycleEvent{
ActionID: normalized.ID,
Timestamp: now,
State: ActionStateExpired,
Actor: strings.TrimSpace(actor),
Message: "Action plan expired before dispatch.",
})
return normalized, event, err
}
// BeginActionExecution moves an explicitly executable action into executing.
// Approval remains separate from execution: approval-required plans must be
// approved first, while approval-free plans may start from planned only through
// this explicit execution contract.
func BeginActionExecution(record ActionAuditRecord, actor string, now time.Time) (ActionAuditRecord, ActionLifecycleEvent, error) {
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
if err := ValidateActionExecutionStart(record, now); err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, err
}
actor = strings.TrimSpace(actor)
if actor == "" {
actor = "api:authenticated"
}
record.State = ActionStateExecuting
record.UpdatedAt = now
normalized, err := NormalizeActionAuditRecord(record)
if err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, err
}
event := ActionLifecycleEvent{
ActionID: normalized.ID,
Timestamp: now,
State: ActionStateExecuting,
Actor: actor,
Message: "Action execution started.",
}
normalizedEvent, err := NormalizeActionLifecycleEvent(event)
if err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, err
}
return normalized, normalizedEvent, nil
}
// CompleteActionExecution records the terminal result for an action that has
// already entered executing state.
func CompleteActionExecution(record ActionAuditRecord, result *ExecutionResult, actor string, now time.Time) (ActionAuditRecord, ActionLifecycleEvent, error) {
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
if record.State != ActionStateExecuting {
return ActionAuditRecord{}, ActionLifecycleEvent{}, ErrActionNotExecuting
}
actor = strings.TrimSpace(actor)
if actor == "" {
actor = "api:authenticated"
}
if result == nil {
result = ExecutorContractViolationResult("executor_nil_result", "Executor returned no result.", record.Plan.RollbackAvailable)
}
result.Output = strings.TrimSpace(result.Output)
result.ErrorMessage = strings.TrimSpace(result.ErrorMessage)
canonical := CanonicalActionResultV2(ActionAuditRecord{ID: record.ID, UpdatedAt: now, Plan: record.Plan, Result: result, VerificationOutcome: VerificationOutcomeFromExecutionResult(result)})
var err error
result, record.VerificationOutcome, err = ApplyActionResultV2(result, canonical)
if err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, err
}
nextState := ActionStateCompleted
message := "Action execution completed."
if !result.Success {
nextState = ActionStateFailed
message = result.ErrorMessage
if message == "" {
message = "Action execution failed."
}
}
record.State = nextState
record.UpdatedAt = now
record.Result = result
normalized, err := NormalizeActionAuditRecord(record)
if err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, err
}
event := ActionLifecycleEvent{
ActionID: normalized.ID,
Timestamp: now,
State: nextState,
Actor: actor,
Message: message,
}
normalizedEvent, err := NormalizeActionLifecycleEvent(event)
if err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, err
}
return normalized, normalizedEvent, nil
}
// RefuseActionExecution records a permanent pre-dispatch refusal as the same
// terminal failed audit shape used by runtime execution failures.
func RefuseActionExecution(record ActionAuditRecord, reason error, actor string, now time.Time) (ActionAuditRecord, ActionLifecycleEvent, error) {
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
message, ok := permanentActionExecutionRefusalMessage(reason)
if !ok {
return ActionAuditRecord{}, ActionLifecycleEvent{}, fmt.Errorf("%w: %v", ErrActionExecutionRefusal, reason)
}
actor = strings.TrimSpace(actor)
if actor == "" {
actor = "api:authenticated"
}
record.State = ActionStateFailed
record.UpdatedAt = now
record.Result = KnownNoEffectResult("pre_dispatch_refused", message, record.Plan.RollbackAvailable)
normalized, err := NormalizeActionAuditRecord(record)
if err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, err
}
event := ActionLifecycleEvent{
ActionID: normalized.ID,
Timestamp: now,
State: ActionStateFailed,
Actor: actor,
Message: normalized.Result.ErrorMessage,
}
normalizedEvent, err := NormalizeActionLifecycleEvent(event)
if err != nil {
return ActionAuditRecord{}, ActionLifecycleEvent{}, err
}
return normalized, normalizedEvent, nil
}
// IsPermanentActionExecutionRefusal reports whether err represents a
// non-dispatchable execution attempt that should terminally fail the audit.
func IsPermanentActionExecutionRefusal(err error) bool {
_, ok := permanentActionExecutionRefusalMessage(err)
return ok
}
func permanentActionExecutionRefusalMessage(reason error) (string, bool) {
switch {
case errors.Is(reason, ErrActionPlanDrift):
return "plan_drift: action plan no longer matches the current resource contract; re-plan before executing", true
case errors.Is(reason, ErrActionPlanExpired):
return "action_plan_expired: action plan has expired; re-plan before executing", true
case errors.Is(reason, ErrActionDryRunOnly):
return "action_dry_run_only: action plan is dry-run only and cannot be executed", true
case errors.Is(reason, ErrResourceRemediationLocked):
return "resource_remediation_locked: resource is operator-locked against automated remediation", true
case errors.Is(reason, ErrActionPolicyAuthorizationExpired):
return "policy_authorization_expired: automatic authority expired before dispatch", true
case errors.Is(reason, ErrActionPolicyAuthorizationInvalid):
return "policy_authorization_invalid: automatic authority is missing, unreadable, or malformed", true
case errors.Is(reason, ErrActionPolicyAuthorizationRevoked):
return "policy_authorization_revoked: automatic authority changed before dispatch", true
case errors.Is(reason, ErrActionEmergencyStop):
return "action_emergency_stop: action dispatch is stopped by the operator", true
case errors.Is(reason, ErrActionReplanRequired):
return "action_replan_required: legacy action authority is unbound; re-plan before deciding or executing", true
default:
return "", false
}
}
// ValidateActionExecutionStart checks whether the current persisted state may
// enter execution without mutating the record.
func ValidateActionExecutionStart(record ActionAuditRecord, now time.Time) error {
if now.IsZero() {
now = time.Now().UTC()
} else {
now = now.UTC()
}
switch record.State {
case ActionStateExecuting:
return ErrActionAlreadyExecuting
case ActionStateExpired, ActionStateCompleted, ActionStateFailed:
return ErrActionExecutionFinal
}
if !record.Plan.ExpiresAt.IsZero() && !now.Before(record.Plan.ExpiresAt) {
return ErrActionPlanExpired
}
if record.Plan.ApprovalPolicy == ApprovalDryRun {
return ErrActionDryRunOnly
}
switch record.State {
case ActionStateApproved:
return nil
case ActionStatePlanned:
if record.Plan.Allowed && !record.Plan.RequiresApproval {
return nil
}
return ErrActionNotApproved
case ActionStatePending, ActionStateRejected, ActionStateExpired:
return ErrActionNotApproved
default:
return fmt.Errorf("unsupported action state %q", record.State)
}
}
// ValidateHumanActionBinding rejects legacy nonterminal records that predate
// the immutable actor/requirement contract. Terminal history remains readable,
// while pending or approved work must be re-planned under current authority.
func ValidateHumanActionBinding(record ActionAuditRecord, orgID string) error {
record.Request.Actor = NormalizeActionActor(record.Request.Actor)
requirement := NormalizeApprovalRequirement(record.Plan.ApprovalRequirement, record.Plan.ApprovalPolicy)
if ValidateActionActor(record.Request.Actor) != nil || record.Request.Actor.OrgID != strings.TrimSpace(orgID) ||
requirement.Version != ActionApprovalRequirementVersion || requirement.Floor != record.Plan.ApprovalPolicy {
return ErrActionReplanRequired
}
if record.Plan.RequiresApproval && (record.State == ActionStateApproved || record.State == ActionStateExecuting) {
actors := map[string]struct{}{}
for _, approval := range record.Approvals {
if approval.Outcome != OutcomeApproved || approval.Evidence == nil {
continue
}
actor := NormalizeActionActor(approval.ActorBinding)
evidence := *approval.Evidence
evidence.Actor = NormalizeActionActor(evidence.Actor)
if ValidateActionActor(actor) != nil || !ActionActorsEqual(actor, evidence.Actor) ||
evidence.Version != 1 || evidence.OrgID != strings.TrimSpace(orgID) || evidence.ActionID != record.ID ||
evidence.PlanHash != record.Plan.PlanHash || evidence.Outcome != OutcomeApproved || evidence.IssuedAt.IsZero() {
continue
}
if requirement.Floor == ApprovalMultiFactor {
if evidence.Method != MethodWebAuthnUV && evidence.Method != MethodDeviceKeyUV {
continue
}
} else if evidence.Method != MethodSession && evidence.Method != MethodAPIToken {
continue
}
actors[strings.ToLower(actor.SubjectID)] = struct{}{}
}
if len(actors) < requirement.Quorum {
return ErrActionReplanRequired
}
}
return nil
}
// 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.Plan.PolicyDecision.Authorities = append([]ActionPolicyAuthorityFactor(nil), record.Plan.PolicyDecision.Authorities...)
for i := range record.Plan.PolicyDecision.Authorities {
record.Plan.PolicyDecision.Authorities[i].ReasonCodes = append([]ActionPolicyReasonCode(nil), record.Plan.PolicyDecision.Authorities[i].ReasonCodes...)
}
if record.Plan.Preflight != nil {
preflight := *record.Plan.Preflight
preflight.SafetyChecks = append([]string(nil), record.Plan.Preflight.SafetyChecks...)
preflight.VerificationSteps = append([]string(nil), record.Plan.Preflight.VerificationSteps...)
record.Plan.Preflight = &preflight
}
record.Approvals = append([]ActionApprovalRecord(nil), record.Approvals...)
for i := range record.Approvals {
record.Approvals[i].ActorBinding = NormalizeActionActor(record.Approvals[i].ActorBinding)
if record.Approvals[i].Evidence != nil {
evidence := *record.Approvals[i].Evidence
evidence.Actor = NormalizeActionActor(evidence.Actor)
record.Approvals[i].Evidence = &evidence
}
if record.Approvals[i].PolicyLease != nil {
lease := *record.Approvals[i].PolicyLease
lease.CapabilityNames = append([]string(nil), lease.CapabilityNames...)
if lease.Window != nil {
window := *lease.Window
lease.Window = &window
}
record.Approvals[i].PolicyLease = &lease
}
}
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)
record.Request.Actor = NormalizeActionActor(record.Request.Actor)
if record.Request.Actor.SubjectID != "" {
record.Request.RequestedBy = record.Request.Actor.SubjectID
}
record.Origin = NormalizeActionOrigin(record.Origin)
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.ApprovalRequirement = NormalizeApprovalRequirement(record.Plan.ApprovalRequirement, record.Plan.ApprovalPolicy)
if record.Plan.ApprovalRequirement.Version != 0 && record.Plan.ApprovalRequirement.Version != ActionApprovalRequirementVersion {
return ActionAuditRecord{}, fmt.Errorf("unsupported approval requirement version %d", record.Plan.ApprovalRequirement.Version)
}
if record.Plan.PolicyDecision.Version == 0 && record.Plan.PolicyDecision.Status == "" {
record.Plan.PolicyDecision = LegacyUnknownActionPolicyDecision()
}
if err := ValidateActionPlanPolicyDecision(record.Plan, record.Request); err != nil {
return ActionAuditRecord{}, fmt.Errorf("invalid action policy decision: %w", err)
}
record.Plan.Preflight = NormalizeActionPreflight(record.Plan.Preflight, record.Request, record.Plan)
if record.Result != nil {
result := *record.Result
result.Output = strings.TrimSpace(result.Output)
result.ErrorMessage = strings.TrimSpace(result.ErrorMessage)
if result.Verification == nil {
result.Verification = cloneActionVerificationResult(record.Verification)
}
canonical := CanonicalActionResultV2(ActionAuditRecord{ID: record.ID, UpdatedAt: record.UpdatedAt, Plan: record.Plan, Result: &result, VerificationOutcome: record.VerificationOutcome})
result.Verification = NormalizeActionVerificationResult(result.Verification)
result.ActionResultV2 = &canonical
record.Result = &result
}
record.Verification = NormalizeActionVerificationResult(record.Verification)
if record.Verification == nil && record.Result != nil {
record.Verification = cloneActionVerificationResult(record.Result.Verification)
}
if record.Verification != nil && record.Result != nil {
record.Result.Verification = cloneActionVerificationResult(record.Verification)
}
record.VerificationOutcome = NormalizeVerificationOutcome(record.VerificationOutcome)
if record.Result != nil {
result, legacy, err := ApplyActionResultV2(record.Result, CanonicalActionResultV2(record))
if err != nil {
return ActionAuditRecord{}, err
}
record.Result = result
record.VerificationOutcome = legacy
}
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)
if event.Kind == "" {
if event.DecisionRevision > 0 || event.Decision != nil {
event.Kind = ActionLifecycleEventDecision
} else {
event.Kind = ActionLifecycleEventTransition
}
}
switch event.Kind {
case ActionLifecycleEventTransition:
if event.DecisionRevision != 0 || event.Decision != nil {
return ActionLifecycleEvent{}, fmt.Errorf("state transition event cannot carry decision identity")
}
case ActionLifecycleEventDecision:
if event.DecisionRevision == 0 || event.Decision == nil {
return ActionLifecycleEvent{}, fmt.Errorf("decision event revision and approval are required")
}
decision := *event.Decision
decision.ActorBinding = NormalizeActionActor(decision.ActorBinding)
decision.Actor = strings.TrimSpace(decision.Actor)
decision.Reason = strings.TrimSpace(decision.Reason)
if decision.Actor == "" || decision.Actor != decision.ActorBinding.SubjectID || decision.Evidence == nil ||
decision.Outcome == "" || decision.Method == "" || decision.Timestamp.IsZero() {
return ActionLifecycleEvent{}, fmt.Errorf("decision event approval binding is incomplete")
}
decision.Timestamp = decision.Timestamp.UTC()
evidence := *decision.Evidence
evidence.Actor = NormalizeActionActor(evidence.Actor)
if !ActionActorsEqual(evidence.Actor, decision.ActorBinding) || evidence.ActionID != event.ActionID ||
evidence.Outcome != decision.Outcome || evidence.Method != decision.Method {
return ActionLifecycleEvent{}, fmt.Errorf("decision event evidence binding does not match approval")
}
decision.Evidence = &evidence
event.Decision = &decision
event.Actor = decision.Actor
event.Timestamp = decision.Timestamp
default:
return ActionLifecycleEvent{}, fmt.Errorf("unsupported action lifecycle event kind %q", event.Kind)
}
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, ActionStateExpired, ActionStateExecuting, ActionStateCompleted, ActionStateFailed:
return true
default:
return false
}
}