From 71a3b6ebcdd02a3c0c0f53267048edffdc1c86f9 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 13 Jul 2026 21:51:33 +0100 Subject: [PATCH] Restore release-blocking backend contracts --- .../v6/internal/subsystems/ai-runtime.md | 22 +++- .../v6/internal/subsystems/monitoring.md | 6 ++ .../internal/subsystems/unified-resources.md | 5 +- internal/ai/findings_apt_workflows.go | 3 + internal/ai/service_extended_test.go | 94 ++++------------ internal/ai/service_test.go | 4 +- internal/ai/tools/action_audit.go | 101 ++++++++++++++---- .../ai/tools/action_audit_execution_test.go | 8 +- internal/api/actions_test.go | 10 +- internal/mock/generator.go | 16 +++ internal/mock/generator_test.go | 18 ++++ .../runtime_surface_audit_test.go | 1 + internal/unifiedresources/action_result_v2.go | 4 + .../unifiedresources/action_result_v2_test.go | 35 ++++++ .../unifiedresources/code_standards_test.go | 3 + 15 files changed, 223 insertions(+), 107 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 32758d5d0..dcad68c58 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -3644,13 +3644,16 @@ may keep first-party model-facing descriptions, but it must not re-declare required arguments such as `resolution_note` or dismissal `note` when the canonical Pulse Intelligence manifest and API contract mark those fields optional. -Legacy native Assistant utility provider aliases for `run_command`, `fetch_url`, -and `set_resource_url`, plus their provider JSON schemas, are owned by +Legacy native Assistant utility provider aliases for `fetch_url` and +`set_resource_url`, plus their provider JSON schemas, are owned by `agentcapabilities.LegacyAssistantUtilityProviderTools`. The older native Assistant service may continue to expose those compatibility aliases while the execution migration proceeds, but it must consume the shared provider projection and shared argument constants rather than carrying inline schema maps or local -tool-input string keys. +tool-input string keys. The legacy `run_command` alias is retired: the Assistant +must not offer it, and a fabricated invocation must fail closed before any +command dispatch. Raw command execution remains available only through the +governed capability and action-lifecycle path. Native Assistant registry tools that operate on Patrol finding lifecycle state must also consume the same `agentcapabilities` argument vocabulary for `finding_id`, `resolution_note`, `reason`, and `note` instead of repeating @@ -5855,6 +5858,15 @@ biometric claims never satisfy an MFA floor. Until the core step-up verifier accepts action-bound cryptographic evidence, the honest runtime outcome is step-up unavailable, not MFA approved. +The legacy Patrol approval bridge must project stored approvals into that same +server-owned model before a lifecycle decision is appended. It binds a scoped +service requester, the deciding human `ActorBinding`, and method/session +`ApprovalEvidence` to the exact action, plan hash, outcome, organization, and +decision time. A zero-version legacy approval requirement is upgraded to the +current canonical floor before evaluation; missing or inconsistent authority +fails closed. A dry-run plan is never executable and must persist refusal +without minting decision approval authority. + Patrol action proposals persist the exact bounded policy authorities consulted at planning time through the canonical unified-resource `policyDecision` object. Capability, tenant Patrol, and resource operator factors retain typed @@ -5907,6 +5919,10 @@ exercised through an exact empty-parameter proposal, shared planning/policy and human approval, durable typed dispatch, canonical terminal audit, and finding resolution for both workflows. Finding evidence stays bounded and contains no command, path, package selector, raw APT output, stderr, or reboot authority. +The APT verifier claims only canonical APT finding keys. It must return +unhandled for CPU, memory, disk, and every other non-APT key so the owning +deterministic verifier can evaluate that finding; APT resource lookup failure +must never intercept unrelated verification. Fake-only callback-loss tests reopen the server-side action store and consume Task 07 terminal receipts without resending either typed mutation, then diff --git a/docs/release-control/v6/internal/subsystems/monitoring.md b/docs/release-control/v6/internal/subsystems/monitoring.md index e36cbbf43..ed9cf0071 100644 --- a/docs/release-control/v6/internal/subsystems/monitoring.md +++ b/docs/release-control/v6/internal/subsystems/monitoring.md @@ -945,6 +945,12 @@ so the Kubernetes platform-page Configuration tab exercises the same RBAC summary-count contract live agents use. The `TestKubernetesDemoClustersTellDistinctStories` test in `internal/mock/demo_scenarios_test.go` guards that distribution. +Generic Kubernetes fixture synthesis must also preserve at least one ready, +schedulable node whenever a cluster has nodes. Randomized readiness and cordon +stories may degrade the remaining nodes, but they must not accidentally create +a total outage that erases running-pod metrics or makes the demo and its proof +nondeterministic; explicit curated outage scenarios remain the owner of +cluster-wide unavailability. Bumps to those defaults must keep the curated demo scenario's per-node hostname seasoning in `demo_scenarios.go` aligned (today: pve1..pve6 with regional labels, diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index ab7a86ed5..661b65e3d 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1819,7 +1819,10 @@ and malformed evidence remain explicitly inconclusive. Legacy `Success` and Canonical evidence is bounded before redaction and digested as SHA-256 over the canonical redacted envelope. Redaction deep-copies nested evidence and fails closed to `redaction_contract_violation`; it never preserves invalid or -unredacted input. A present but malformed stored V2 also fails closed to +unredacted input. Result normalization is copy-on-normalize across evidence, +verification, and nested compensation, so callers may safely normalize shared +immutable snapshots without mutating their stored input or racing other +readers. A present but malformed stored V2 also fails closed to `result_v2_invalid`; legacy booleans can never override it. Compensation truth never rewrites the primary result and carries declared trigger, durable attempt/step identity, timing, nested execution and verification, and restored diff --git a/internal/ai/findings_apt_workflows.go b/internal/ai/findings_apt_workflows.go index 60aa4bcd2..02bfbb0e0 100644 --- a/internal/ai/findings_apt_workflows.go +++ b/internal/ai/findings_apt_workflows.go @@ -188,6 +188,9 @@ func formatAPTBytes(value int64) string { } func verifyAPTWorkflowFinding(state patrolRuntimeState, findingKey, resourceID string, now time.Time) (bool, bool, error) { + if findingKey != aptHostUpdateFindingKey && findingKey != aptPackageCacheFindingKey { + return false, false, nil + } if state.readState == nil { state = state.withDerivedProviders() } diff --git a/internal/ai/service_extended_test.go b/internal/ai/service_extended_test.go index 858b012c2..e4504ee53 100644 --- a/internal/ai/service_extended_test.go +++ b/internal/ai/service_extended_test.go @@ -142,17 +142,13 @@ func TestService_GetTools(t *testing.T) { t.Error("Expected tools") } - // Verify some common tools are present - foundRunCommand := false + // The retired raw-command alias must not be projected even when the legacy + // control level is configured to allow governed actions. for _, tool := range tools { if tool.Name == agentcapabilities.LegacyAssistantRunCommandToolName { - foundRunCommand = true - break + t.Fatal("retired run_command alias was projected to the provider") } } - if !foundRunCommand { - t.Error("Expected run_command tool to be available") - } } func TestService_GetToolsUsesSharedLegacyUtilitySchemas(t *testing.T) { @@ -815,10 +811,15 @@ func TestService_GetModelForRequest(t *testing.T) { } func TestService_ExecuteTool_RunCommand(t *testing.T) { + dispatched := false mockAgentServer := &mockAgentServer{ agents: []agentexec.ConnectedAgent{ {Hostname: "node-1", AgentID: "agent-1"}, }, + executeFunc: func(context.Context, string, agentexec.ExecuteCommandPayload) (*agentexec.CommandResultPayload, error) { + dispatched = true + return &agentexec.CommandResultPayload{Success: true}, nil + }, } svc := NewService(nil, mockAgentServer) svc.cfg = &config.AIConfig{ControlLevel: config.ControlLevelControlled} @@ -841,57 +842,12 @@ func TestService_ExecuteTool_RunCommand(t *testing.T) { Context: map[string]interface{}{"node": "node-1"}, } - // 1. Allowed command result, exec := svc.executeTool(ctx, req, tc) - if !exec.Success { - t.Errorf("Expected success, got failure: %s", result) + if exec.Success || !strings.Contains(result, "Invocation blocked") { + t.Fatalf("retired run_command did not fail closed: success=%v result=%q", exec.Success, result) } - - // 2. Blocked command - mockPolicy.decision = agentexec.PolicyBlock - result, exec = svc.executeTool(ctx, req, tc) - if exec.Success { - t.Error("Expected failure for blocked command") - } - if !strings.Contains(result, "blocked") { - t.Errorf("Expected blocked message, got %s", result) - } - - // 3. Approval required (non-autonomous) - mockPolicy.decision = agentexec.PolicyRequireApproval - svc.cfg = &config.AIConfig{ControlLevel: config.ControlLevelControlled} - result, exec = svc.executeTool(ctx, req, tc) - if !exec.Success { - t.Errorf("Expected success (not an error to need approval), got: %s", result) - } - if !strings.Contains(result, "APPROVAL_REQUIRED") { - t.Errorf("Expected APPROVAL_REQUIRED message, got %s", result) - } - - // 4. Request-scoped approval mode must clamp an autonomous default. - autonomousMode := false - svc.cfg = &config.AIConfig{ControlLevel: config.ControlLevelAutonomous} - req.AutonomousMode = &autonomousMode - result, exec = svc.executeTool(ctx, req, tc) - if !exec.Success { - t.Errorf("Expected success (not an error to need approval), got: %s", result) - } - if !strings.Contains(result, "APPROVAL_REQUIRED") { - t.Errorf("Expected request-scoped approval mode to require approval, got %s", result) - } - - // 5. Approval-bound handoffs require approval even for policy-allowed commands. - mockPolicy.decision = agentexec.PolicyAllow - req.RequireCommandApproval = true - result, exec = svc.executeTool(ctx, req, tc) - if !exec.Success { - t.Errorf("Expected success (not an error to need approval), got: %s", result) - } - if !strings.Contains(result, "APPROVAL_REQUIRED") { - t.Errorf("Expected approval-bound handoff to require approval, got %s", result) - } - if !strings.Contains(result, "handoff requires operator approval") { - t.Errorf("Expected handoff approval reason, got %s", result) + if dispatched { + t.Fatal("retired run_command reached the agent transport") } } @@ -1282,25 +1238,16 @@ func TestService_ExecuteTool_RunCommand_WithTargetHost(t *testing.T) { } result, exec := svc.executeTool(context.Background(), req, tc) - if !exec.Success { - t.Errorf("Expected success, got failure: %s", result) - } - if exec.Input != "[target-node] uptime" { - t.Errorf("Expected input to show target-node, got: %s", exec.Input) + if exec.Success || !strings.Contains(result, "Invocation blocked") { + t.Fatalf("retired run_command did not fail closed: success=%v result=%q", exec.Success, result) } changes, err := canonicalStore.GetRecentChanges("vm-100", time.Time{}, 10) if err != nil { t.Fatalf("GetRecentChanges: %v", err) } - if len(changes) != 1 { - t.Fatalf("expected 1 canonical command change, got %d", len(changes)) - } - if changes[0].Kind != unifiedresources.ChangeCommandExecuted { - t.Fatalf("Kind = %q, want %q", changes[0].Kind, unifiedresources.ChangeCommandExecuted) - } - if got := changes[0].Metadata["alert_identifier"]; got != "alert-123" { - t.Fatalf("alert_identifier = %#v, want alert-123", got) + if len(changes) != 0 { + t.Fatalf("retired run_command wrote canonical change history: %#v", changes) } } @@ -3244,12 +3191,11 @@ func TestService_FindingContextCommandRouting(t *testing.T) { result, exec := svc.executeTool(context.Background(), req, tc) - if !exec.Success { - t.Errorf("Command execution failed: %s", result) + if exec.Success || !strings.Contains(result, "Invocation blocked") { + t.Fatalf("retired run_command did not fail closed: success=%v result=%q", exec.Success, result) } - // Verify it routed to the correct agent (minipc, not pve-node) - if routedToAgent != "agent-minipc" { - t.Errorf("Expected command to route to agent-minipc, but went to: %s", routedToAgent) + if routedToAgent != "" { + t.Fatalf("retired run_command reached agent routing: %s", routedToAgent) } } diff --git a/internal/ai/service_test.go b/internal/ai/service_test.go index 36af7d3df..fc5b58912 100644 --- a/internal/ai/service_test.go +++ b/internal/ai/service_test.go @@ -1184,8 +1184,8 @@ func TestService_ExecuteTool(t *testing.T) { } output, exec := svc.executeTool(ctx, req, tc) - if !containsString(output, "agent server not available") { - t.Errorf("Expected agent server error, got: %s", output) + if !containsString(output, "Invocation blocked") { + t.Errorf("expected retired run_command to fail closed, got: %s", output) } if exec.Success { t.Error("Expected failure") diff --git a/internal/ai/tools/action_audit.go b/internal/ai/tools/action_audit.go index 72a48f68e..f04c41d10 100644 --- a/internal/ai/tools/action_audit.go +++ b/internal/ai/tools/action_audit.go @@ -84,7 +84,7 @@ func (e *PulseToolExecutor) executeCommandWithAudit( if planFromApproval { plan = mergeApprovedActionPlan(*approvalReq.Plan, plan) } - approvalRecords := approvalRecordsForID(approvalID) + approvalRecords := approvalRecordsForID(approvalID, &plan) record := unifiedresources.ActionAuditRecord{ ID: actionID, @@ -293,7 +293,7 @@ func (e *PulseToolExecutor) executeNativeActionWithAudit( if planFromApproval { plan = mergeApprovedActionPlan(*approvalReq.Plan, plan) } - approvalRecords := approvalRecordsForID(approvalID) + approvalRecords := approvalRecordsForID(approvalID, &plan) record := unifiedresources.ActionAuditRecord{ ID: actionID, @@ -371,6 +371,15 @@ func (e *PulseToolExecutor) recordActionExecutionStart(record unifiedresources.A actor = approvalAuditActor } if planFromApproval { + if record.Plan.ApprovalPolicy == unifiedresources.ApprovalDryRun { + refusalRecord := record + if e != nil && e.actionAuditStore != nil { + if current, found, err := e.actionAuditStore.GetActionAudit(record.ID); err == nil && found { + refusalRecord = current + } + } + return e.recordActionExecutionRefusal(refusalRecord, unifiedresources.ErrActionDryRunOnly, actor, now) + } var err error approvalRecord := record record, err = e.ensureApprovalDecisionBeforeExecution(record, approvalID, actor, now) @@ -490,7 +499,7 @@ func (e *PulseToolExecutor) ensureApprovalDecisionBeforeExecution(record unified return current, nil } - approvalRecord := actionApprovalRecordForExecution(approvalID, actor, now, unifiedresources.OutcomeApproved) + approvalRecord := actionApprovalRecordForExecution(current, approvalID, actor, now, unifiedresources.OutcomeApproved) updated, event, err := unifiedresources.ApplyActionDecision(current, approvalRecord, approvalRecord.Timestamp) if err != nil { log.Warn().Err(err).Str("action_id", record.ID).Msg("failed to normalize approval decision before execution") @@ -503,11 +512,11 @@ func (e *PulseToolExecutor) ensureApprovalDecisionBeforeExecution(record unified return updated, nil } -func actionApprovalRecordForExecution(approvalID, actor string, now time.Time, fallbackOutcome unifiedresources.ApprovalOutcome) unifiedresources.ActionApprovalRecord { +func actionApprovalRecordForExecution(action unifiedresources.ActionAuditRecord, approvalID, actor string, now time.Time, fallbackOutcome unifiedresources.ApprovalOutcome) unifiedresources.ActionApprovalRecord { if fallbackOutcome == "" { fallbackOutcome = unifiedresources.OutcomeApproved } - records := approvalRecordsForID(approvalID) + records := approvalRecordsForID(approvalID, &action.Plan) if len(records) > 0 { record := records[len(records)-1] if record.Outcome == "" { @@ -521,11 +530,15 @@ func actionApprovalRecordForExecution(approvalID, actor string, now time.Time, f } return record } + actorBinding := legacyApprovalActorBinding(approvalID, actor, action.Request.Actor.OrgID) + evidence := legacyApprovalEvidence(actorBinding, &action.Plan, fallbackOutcome, now) return unifiedresources.ActionApprovalRecord{ - Actor: actor, - Method: unifiedresources.MethodAPI, - Timestamp: now, - Outcome: fallbackOutcome, + Actor: actorBinding.SubjectID, + ActorBinding: actorBinding, + Method: evidence.Method, + Timestamp: now, + Outcome: fallbackOutcome, + Evidence: &evidence, } } @@ -617,7 +630,7 @@ func RecordApprovalDecision(store unifiedresources.ResourceStore, approvalID str message = string(state) } record := actionAuditRecordFromApproval(req, state, actor) - record.Approvals = approvalRecordsForID(req.ID) + record.Approvals = approvalRecordsForID(req.ID, req.Plan) if recordApprovalDecisionAtomically(store, req.ID, record, actor) { return } @@ -665,7 +678,7 @@ func recordApprovalDecisionAtomically(store unifiedresources.ResourceStore, appr if record.State == unifiedresources.ActionStateRejected { outcome = unifiedresources.OutcomeRejected } - approvalRecord := actionApprovalRecordForExecution(approvalID, actor, time.Now().UTC(), outcome) + approvalRecord := actionApprovalRecordForExecution(current, approvalID, actor, time.Now().UTC(), outcome) approvalRecord.Outcome = outcome updated, event, err := unifiedresources.ApplyActionDecision(current, approvalRecord, approvalRecord.Timestamp) if err != nil { @@ -803,9 +816,15 @@ func mergeApprovedActionPlan(approved unifiedresources.ActionPlan, fallback unif return approved } -func actionAuditRecordFromApproval(req *approval.ApprovalRequest, state unifiedresources.ActionState, actor string) unifiedresources.ActionAuditRecord { +func actionAuditRecordFromApproval(req *approval.ApprovalRequest, state unifiedresources.ActionState, _ string) unifiedresources.ActionAuditRecord { now := time.Now().UTC() plan := *req.Plan + if plan.ApprovalPolicy == "" { + plan.ApprovalPolicy = unifiedresources.ApprovalAdmin + } + if plan.ApprovalRequirement.Version == 0 { + plan.ApprovalRequirement = unifiedresources.ApprovalRequirementForFloor(plan.ApprovalPolicy) + } createdAt := plan.PlannedAt if createdAt.IsZero() { createdAt = now @@ -824,6 +843,13 @@ func actionAuditRecordFromApproval(req *approval.ApprovalRequest, state unifiedr if orgID := strings.TrimSpace(req.OrgID); orgID != "" { params["orgId"] = orgID } + requestedBy := approval.RequesterForRequest(req) + requestActor := unifiedresources.ActionActor{ + SubjectID: requestedBy, + Kind: unifiedresources.ActionActorService, + CredentialID: "approval-request:" + strings.TrimSpace(req.ID), + OrgID: approval.NormalizeOrgID(req.OrgID), + } return unifiedresources.ActionAuditRecord{ ID: plan.ActionID, CreatedAt: createdAt, @@ -835,7 +861,8 @@ func actionAuditRecordFromApproval(req *approval.ApprovalRequest, state unifiedr CapabilityName: approvalCapabilityForTargetType(req.TargetType), Params: params, Reason: strings.TrimSpace(req.Context), - RequestedBy: actor, + RequestedBy: requestedBy, + Actor: requestActor, }, Plan: plan, } @@ -989,7 +1016,7 @@ func actionAuditPreflight(resourceID, reason string, generatedAt time.Time) *uni }, unifiedresources.ActionRequest{ResourceID: resourceID, Reason: reason}, unifiedresources.ActionPlan{PlannedAt: generatedAt, Message: reason}) } -func approvalRecordsForID(approvalID string) []unifiedresources.ActionApprovalRecord { +func approvalRecordsForID(approvalID string, plan *unifiedresources.ActionPlan) []unifiedresources.ActionApprovalRecord { approvalID = strings.TrimSpace(approvalID) if approvalID == "" { return nil @@ -1003,19 +1030,49 @@ func approvalRecordsForID(approvalID string) []unifiedresources.ActionApprovalRe return nil } - record := unifiedresources.ActionApprovalRecord{ - Actor: strings.TrimSpace(req.DecidedBy), - Method: unifiedresources.MethodAPI, - Timestamp: approvalTimestamp(req), - Outcome: unifiedresources.OutcomeApproved, - Reason: strings.TrimSpace(req.Context), - } + outcome := unifiedresources.OutcomeApproved if req.Status == approval.StatusDenied { - record.Outcome = unifiedresources.OutcomeRejected + outcome = unifiedresources.OutcomeRejected + } + actorBinding := legacyApprovalActorBinding(approvalID, req.DecidedBy, req.OrgID) + evidence := legacyApprovalEvidence(actorBinding, plan, outcome, approvalTimestamp(req)) + record := unifiedresources.ActionApprovalRecord{ + Actor: actorBinding.SubjectID, + ActorBinding: actorBinding, + Method: evidence.Method, + Timestamp: evidence.IssuedAt, + Outcome: outcome, + Reason: strings.TrimSpace(req.Context), + Evidence: &evidence, } return []unifiedresources.ActionApprovalRecord{record} } +func legacyApprovalActorBinding(approvalID, subject, orgID string) unifiedresources.ActionActor { + return unifiedresources.ActionActor{ + SubjectID: strings.TrimSpace(subject), + Kind: unifiedresources.ActionActorUser, + CredentialID: "approval-session:" + strings.TrimSpace(approvalID), + OrgID: approval.NormalizeOrgID(orgID), + } +} + +func legacyApprovalEvidence(actor unifiedresources.ActionActor, plan *unifiedresources.ActionPlan, outcome unifiedresources.ApprovalOutcome, issuedAt time.Time) unifiedresources.ApprovalEvidence { + evidence := unifiedresources.ApprovalEvidence{ + Version: 1, + Method: unifiedresources.MethodSession, + Actor: actor, + OrgID: actor.OrgID, + Outcome: outcome, + IssuedAt: issuedAt.UTC(), + } + if plan != nil { + evidence.ActionID = strings.TrimSpace(plan.ActionID) + evidence.PlanHash = strings.TrimSpace(plan.PlanHash) + } + return evidence +} + func approvalTimestamp(req *approval.ApprovalRequest) time.Time { if req == nil || req.DecidedAt == nil { return time.Now().UTC() diff --git a/internal/ai/tools/action_audit_execution_test.go b/internal/ai/tools/action_audit_execution_test.go index 454ac964a..854e04cb6 100644 --- a/internal/ai/tools/action_audit_execution_test.go +++ b/internal/ai/tools/action_audit_execution_test.go @@ -461,7 +461,7 @@ func TestRecordApprovalDecisionDoesNotRegressExecutingAudit(t *testing.T) { } executor := NewPulseToolExecutor(ExecutorConfig{ActionAuditStore: actionStore}) record := actionAuditRecordFromApproval(req, unifiedresources.ActionStateExecuting, "pulse_control") - record.Approvals = approvalRecordsForID(req.ID) + record.Approvals = approvalRecordsForID(req.ID, req.Plan) if err := actionStore.RecordActionAudit(record); err != nil { t.Fatalf("RecordActionAudit: %v", err) } @@ -1104,7 +1104,11 @@ func TestExecuteCommandWithAuditRefusesApprovedDryRunOnlyAndExpiredPlans(t *test if !strings.HasPrefix(audit.Result.ErrorMessage, tc.wantPrefix) { t.Fatalf("ErrorMessage = %q, want prefix %q", audit.Result.ErrorMessage, tc.wantPrefix) } - if len(audit.Approvals) != 1 || audit.Approvals[0].Outcome != unifiedresources.OutcomeApproved { + if errors.Is(tc.wantErr, unifiedresources.ErrActionDryRunOnly) { + if len(audit.Approvals) != 0 { + t.Fatalf("dry-run-only refusal minted executable approval authority: %#v", audit.Approvals) + } + } else if len(audit.Approvals) != 1 || audit.Approvals[0].Outcome != unifiedresources.OutcomeApproved { t.Fatalf("expected approved audit record to be preserved, got %#v", audit.Approvals) } diff --git a/internal/api/actions_test.go b/internal/api/actions_test.go index 856563917..b2fa1abc6 100644 --- a/internal/api/actions_test.go +++ b/internal/api/actions_test.go @@ -367,7 +367,7 @@ func TestHandleListPendingActionsReturnsOnlyCanonicalDecisionQueue(t *testing.T) } func TestHandleListAndDetailActionsProjectCanonicalResourcePresentation(t *testing.T) { - now := time.Date(2026, 7, 13, 15, 0, 0, 0, time.UTC) + now := time.Now().UTC().Truncate(time.Second) h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()}) h.SetStateProvider(resourceUnifiedSeedProvider{ snapshot: models.StateSnapshot{LastUpdate: now}, @@ -386,11 +386,15 @@ func TestHandleListAndDetailActionsProjectCanonicalResourcePresentation(t *testi } record := unified.ActionAuditRecord{ ID: "act-resource-presentation", CreatedAt: now, UpdatedAt: now, State: unified.ActionStatePending, - Request: unified.ActionRequest{RequestID: "req-resource-presentation", ResourceID: "vm:42", CapabilityName: "restart", Reason: "Recover checkout", RequestedBy: "pulse_patrol"}, + Request: unified.ActionRequest{ + RequestID: "req-resource-presentation", ResourceID: "vm:42", CapabilityName: "restart", Reason: "Recover checkout", RequestedBy: "pulse_patrol", + Actor: unified.ActionActor{SubjectID: "pulse_patrol", Kind: unified.ActionActorService, CredentialID: "service:test-patrol", OrgID: "default"}, + }, Plan: unified.ActionPlan{ ActionID: "act-resource-presentation", RequestID: "req-resource-presentation", Allowed: true, RequiresApproval: true, ApprovalPolicy: unified.ApprovalAdmin, - PlannedAt: now, ExpiresAt: now.Add(4 * time.Hour), PlanHash: "sha256:resource-presentation", + ApprovalRequirement: unified.ApprovalRequirementForFloor(unified.ApprovalAdmin), + PlannedAt: now, ExpiresAt: now.Add(4 * time.Hour), PlanHash: "sha256:resource-presentation", }, } if err := store.RecordActionAudit(record); err != nil { diff --git a/internal/mock/generator.go b/internal/mock/generator.go index 83f91a058..f2a356ac6 100644 --- a/internal/mock/generator.go +++ b/internal/mock/generator.go @@ -2132,6 +2132,22 @@ func generateKubernetesNodes(clusterID string, count int) []models.KubernetesNod nodes[idx].Ready = false } + // A non-empty demo cluster must retain at least one node that can run + // workloads. Independent readiness and scheduling rolls can otherwise + // produce a fully unavailable estate, making pod placement and the metric + // story nondeterministic even when no outage scenario was requested. + hasSchedulableReadyNode := false + for _, node := range nodes { + if node.Ready && !node.Unschedulable { + hasSchedulableReadyNode = true + break + } + } + if !hasSchedulableReadyNode { + nodes[0].Ready = true + nodes[0].Unschedulable = false + } + return nodes } diff --git a/internal/mock/generator_test.go b/internal/mock/generator_test.go index 11f06fdc8..54e60e7ab 100644 --- a/internal/mock/generator_test.go +++ b/internal/mock/generator_test.go @@ -380,6 +380,24 @@ func TestBuildFixtureStatePopulatesKubernetesUsageMetrics(t *testing.T) { } } +func TestGenerateKubernetesNodesAlwaysRetainsSchedulableReadyNode(t *testing.T) { + for count := 1; count <= 8; count++ { + for iteration := 0; iteration < 100; iteration++ { + nodes := generateKubernetesNodes("fixture-cluster", count) + hasSchedulableReadyNode := false + for _, node := range nodes { + if node.Ready && !node.Unschedulable { + hasSchedulableReadyNode = true + break + } + } + if !hasSchedulableReadyNode { + t.Fatalf("node count %d iteration %d produced no schedulable ready node: %+v", count, iteration, nodes) + } + } + } +} + func TestBuildFixtureStateIncludesKubernetesDeploymentAPIMetadata(t *testing.T) { cfg := DefaultConfig cfg.K8sClusterCount = 1 diff --git a/internal/mutationregistry/runtime_surface_audit_test.go b/internal/mutationregistry/runtime_surface_audit_test.go index fc598b7ad..8a16fb36f 100644 --- a/internal/mutationregistry/runtime_surface_audit_test.go +++ b/internal/mutationregistry/runtime_surface_audit_test.go @@ -33,6 +33,7 @@ var infrastructureRouteCatalog = map[string]routeClassification{ "/api/updates/plan": {}, "/api/updates/history": {}, "/api/updates/history/entry": {}, + "/api/updates/release-notes": {}, "/api/ai/run-command": {MutationID: "legacy.api.run-command"}, "/api/ai/remediation/plans": {}, "/api/ai/remediation/plan": {}, diff --git a/internal/unifiedresources/action_result_v2.go b/internal/unifiedresources/action_result_v2.go index 21920114e..b23c74ce0 100644 --- a/internal/unifiedresources/action_result_v2.go +++ b/internal/unifiedresources/action_result_v2.go @@ -493,6 +493,10 @@ func normalizeRestoredStateTruth(truth ActionRestoredStateTruth) (ActionRestored } func NormalizeActionResultV2(result ActionResultV2) (ActionResultV2, error) { + // Normalization is routinely applied to shared store and mock-fixture + // snapshots. Clone every nested truth surface before canonicalizing it so + // digest calculation and UTC normalization never mutate the caller's copy. + result = cloneActionResultV2(result) if result.Version == 0 { result.Version = ActionResultV2Version } diff --git a/internal/unifiedresources/action_result_v2_test.go b/internal/unifiedresources/action_result_v2_test.go index 564cb033f..c726c9a7b 100644 --- a/internal/unifiedresources/action_result_v2_test.go +++ b/internal/unifiedresources/action_result_v2_test.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "strings" + "sync" "testing" "time" ) @@ -26,6 +27,40 @@ func actionResultTestEvidence(class ActionEvidenceClass) []ActionEvidence { }} } +func TestNormalizeActionResultV2DoesNotMutateSharedInput(t *testing.T) { + input := ActionResultV2{ + Version: ActionResultV2Version, + Execution: ActionExecutionTruth{Status: ActionExecutionSucceeded}, + Verification: ActionVerificationTruth{Status: ActionVerificationConfirmed, EvidenceClass: ActionEvidenceIndependent, Evidence: actionResultTestEvidence(ActionEvidenceIndependent)}, + Compensation: actionResultTestCompensation(), + } + + const callers = 16 + errs := make(chan error, callers) + var wg sync.WaitGroup + for range callers { + wg.Add(1) + go func() { + defer wg.Done() + normalized, err := NormalizeActionResultV2(input) + if err == nil && normalized.Verification.Evidence[0].Digest == "" { + err = errors.New("normalized evidence is missing its digest") + } + errs <- err + }() + } + wg.Wait() + close(errs) + for err := range errs { + if err != nil { + t.Fatal(err) + } + } + if input.Verification.Evidence[0].Digest != "" { + t.Fatalf("normalization mutated shared input digest: %q", input.Verification.Evidence[0].Digest) + } +} + func actionResultTestCompensation() ActionCompensationTruth { return ActionCompensationTruth{Support: ActionCompensationUnavailable, Status: ActionCompensationNotAvailable} } diff --git a/internal/unifiedresources/code_standards_test.go b/internal/unifiedresources/code_standards_test.go index 2e27d3fc8..cf760ad09 100644 --- a/internal/unifiedresources/code_standards_test.go +++ b/internal/unifiedresources/code_standards_test.go @@ -245,6 +245,9 @@ func TestCanonicalActionPlanConstructionCannotBypassPolicyProvenancePlanner(t *t // audit history remain readable but are not canonical action producers. "../api/router_routes_ai_relay.go": true, "../ai/tools/action_audit.go": true, + // Graph-owned mock records are immutable presentation fixtures. They do + // not admit, approve, or dispatch executable actions. + "../mock/action_fixtures.go": true, } pattern := regexp.MustCompile(`(?s)\bActionPlan\s*\{\s*[A-Za-z_][A-Za-z0-9_]*\s*:`) err := filepath.Walk("..", func(path string, info os.FileInfo, err error) error {