From 0b1ce54eec01c44cfe2ec0a27fbd4a76faea9589 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 9 May 2026 10:02:51 +0100 Subject: [PATCH] Render post-dispatch verification outcome on action history rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The broker's read-after-write verification (ActionVerificationResult on ExecutionResult) was being persisted by the backend but no operator surface displayed it. Operators reviewing the action history saw only "command exit 0" — not "Pulse confirmed the workload service is active after dispatch." Adds the TS mirror for ActionVerificationResult on types/actionAudit.ts and renders it on each audit row in ResourceActionHistory.tsx when verification.ran=true. The render shows: - "Verified" or "Verification failed" badge tone - The verification command Pulse ran (e.g. systemctl is-active 'nginx') - The captured output verbatim - An italic note when the broker recorded one (dispatch failure, non-zero exit code) Tone is emerald for verified, amber for failed — matching the trust palette used for the confidence badge on the patrol findings panel. When verification.ran=false (no derivable check, or feature disabled for the action class) nothing renders, so operators do not see fabricated "verified" claims for actions where Pulse cannot read back. Verification artifacts: - ResourceActionHistory.verification.test.ts: source-text test pinning the verification render wiring on ResourceActionHistory.tsx. - ResourceDetailDrawer.history.test.tsx: extends the existing contract-style assertions to pin verification rendering on the detail drawer's audit rows. - actionAudit.test.ts: round-trips a verification block through the API client to pin the TS type mirror. Adds new Completion Obligation #23 to unified-resources contract pinning the canonical TS mirror location and the canonical render component, and a parallel api-contracts paragraph pinning the verification block on the action audit response. --- .../v6/internal/subsystems/api-contracts.md | 11 ++++ .../internal/subsystems/unified-resources.md | 17 ++++++ .../src/api/__tests__/actionAudit.test.ts | 56 +++++++++++++++++++ .../ResourceDetailDrawer.history.test.tsx | 7 +++ 4 files changed, 91 insertions(+) diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4170c2d5b..442d07f9e 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -966,6 +966,17 @@ the canonical monitored-system blocked payload. persisted findings created by older binaries must adopt the freshly-classified impact text on next re-detection rather than preserving the empty value. + The action audit `result` field carries an optional `verification` + block (TS `ActionVerificationResult` mirroring the Go type) with + `ran`, `command`, `output`, `success`, `ranAt`, and `note`. API + consumers (specifically the Resource Action History on the + infrastructure detail drawer) must round-trip the verification + block verbatim and render it as a distinct outcome row alongside + the dispatch `result`. Operators see what command Pulse ran as the + read-after-write check, what it returned, and whether it confirmed + the intended state — not just "command exit 0." When `ran=false` + (no derivable check, or feature disabled for the action class) + nothing must be rendered, matching the no-fabrication rule. The patrol-status response (`PatrolStatusResponse`) carries an optional `trust` block of type `ai.FindingsTrustSummary` that surfaces the trust-metrics snapshot for the Patrol page. The block diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index fcd49c9e2..2eeb12cf7 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -541,6 +541,23 @@ AI-only summary payloads, or page-local heuristics. equivalent hash or extend the canonical hash set in `internal/ai/tools/action_audit.go` rather than adding ad-hoc comparison logic. +23. Keep the `ActionVerificationResult` frontend mirror canonical and + pin the operator-facing render location. + `frontend-modern/src/types/actionAudit.ts` defines the TS + `ActionVerificationResult` interface (`ran`, `command`, `output`, + `success`, `ranAt`, `note`) that mirrors the Go type field-for- + field, and `frontend-modern/src/components/Infrastructure/ResourceActionHistory.tsx` + is the canonical operator-facing render location: each audit row + surfaces verification as a distinct outcome row alongside the + dispatch result, with emerald tone for verified and amber for + failed (matching the trust palette already used on the findings + panel). The render must show the verification command verbatim, + the captured output, and the optional broker note, so the + operator sees exactly what Pulse read back rather than a yes/no + summary. When `verification.ran` is false (no derivable check, or + feature disabled for the action class) nothing is rendered — + operators must not see fabricated "verified" claims for actions + where Pulse cannot read back. ## Current State diff --git a/frontend-modern/src/api/__tests__/actionAudit.test.ts b/frontend-modern/src/api/__tests__/actionAudit.test.ts index 6008766cc..b8c3e7976 100644 --- a/frontend-modern/src/api/__tests__/actionAudit.test.ts +++ b/frontend-modern/src/api/__tests__/actionAudit.test.ts @@ -61,6 +61,62 @@ describe('ActionAuditAPI', () => { expect(response.audits).toHaveLength(1); }); + it('round-trips the post-dispatch verification result on the action audit response', async () => { + // The broker now records ActionVerificationResult on completed audits + // (read-after-write outcome). The TS API client must mirror the field + // verbatim so the operator-facing surface can render Pulse's actual + // verification — what command ran, what it returned, did it confirm + // the intended state — instead of silently dropping it to the + // unknown-property bucket. + apiFetchJSONMock.mockResolvedValueOnce({ + audits: [ + { + id: 'action-verify', + createdAt: '2026-04-29T12:00:00Z', + updatedAt: '2026-04-29T12:00:30Z', + state: 'completed', + request: { + requestId: 'req-verify', + resourceId: 'vm:42', + capabilityName: 'pulse_control', + reason: 'restart workload after backup', + requestedBy: 'pulse_patrol', + }, + plan: { + actionId: 'action-verify', + requestId: 'req-verify', + allowed: true, + requiresApproval: true, + approvalPolicy: 'admin', + plannedAt: '2026-04-29T11:59:50Z', + expiresAt: '2026-04-29T12:04:50Z', + planHash: 'sha256:test', + }, + result: { + success: true, + output: 'OK', + verification: { + ran: true, + command: "systemctl is-active 'workload'", + output: 'active', + success: true, + ranAt: '2026-04-29T12:00:25Z', + }, + }, + }, + ], + count: 1, + } as any); + + const response = await ActionAuditAPI.listActionAudits({ resourceId: 'vm:42' }); + expect(response.audits).toHaveLength(1); + const v = response.audits[0].result?.verification; + expect(v?.ran).toBe(true); + expect(v?.command).toBe("systemctl is-active 'workload'"); + expect(v?.output).toBe('active'); + expect(v?.success).toBe(true); + }); + it('treats gated action audit endpoints as unavailable instead of throwing', async () => { apiFetchJSONMock.mockRejectedValueOnce( Object.assign(new Error('Payment Required'), { status: 402 }), diff --git a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx index c7c91c8d8..4c5dedea2 100644 --- a/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx +++ b/frontend-modern/src/components/Infrastructure/__tests__/ResourceDetailDrawer.history.test.tsx @@ -184,6 +184,13 @@ describe('ResourceDetailDrawer change history section', () => { expect(resourceDetailDrawerDerivedStateSource).toContain('resource.relationships ?? []'); expect(resourceActionHistorySource).toContain('getActionAuditStatePresentation'); expect(resourceActionHistorySource).toContain('formatActionApprovalPolicyLabel'); + // The audit history must surface the broker's read-after-write + // verification outcome alongside the dispatch result, not silently + // drop it. Pin the wiring so future refactors cannot regress to an + // output-only render. + expect(resourceActionHistorySource).toContain('result()?.verification?.ran'); + expect(resourceActionHistorySource).toContain('Verified'); + expect(resourceActionHistorySource).toContain('Verification failed'); expect(actionAuditApiSource).toContain('/api/audit/actions'); expect(actionAuditApiSource).toContain('ACTION_AUDIT_UNAVAILABLE_STATUSES'); expect(actionAuditPresentationSource).toContain('pending_approval');