diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index b47e2f14a..5248667a3 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -119,6 +119,7 @@ continues to mint and quote the enrollment token. 3. `internal/api/discovery_handlers.go` 4. `internal/api/alerts.go` 4a. `internal/api/attention_handlers.go` + 4b. `internal/api/attention_receipts.go` 5. `internal/api/activity_audit_handlers.go` 5a. `pkg/extensions/audit_admin.go` 6. `internal/api/actions.go` @@ -3833,7 +3834,9 @@ the authoritative analysis outcome. `frontend-modern/src/api/patrolAttention.ts` own the typed read transport for `GET /api/ai/patrol/attention`, `GET /api/ai/patrol/attention/summary`, and -`GET /api/ai/patrol/attention/{id}`. Read routes require `monitoring:read`, +`GET /api/ai/patrol/attention/{id}`. The same authenticated transport exposes +`GET /api/ai/patrol/attention/receipts` as the bounded verified-work projection. +Read routes require `monitoring:read`, and — because the attention workbench supersedes the legacy patrol findings routes that mobile devices already used — they equally accept the backend-owned `relay:mobile:access` capability and the legacy `ai:execute` @@ -3846,6 +3849,13 @@ drift back to a single-scope gate: a registered phone carries only mobile alert sync on upgrade (v6.1.0-rc.4 regression). Lists use bounded pagination with a maximum of 200 records and one bounded protection-posture batch. The summary path does not read recovery history. +Receipt reads are separate from lifecycle pagination: the unified action store +filters the trusted `patrol` and `operational_trust_attention` origins and a +canonically confirmed postcondition before applying the requested maximum of +50, orders by verification update newest first, and returns only bounded +presentation fields. A client must not scan a generic resolved-attention or +settled-action page and filter it after pagination, because unrelated history +can otherwise hide genuine verified work. The Patrol workbench may project the summary counts into its fixed lifecycle filter labels at either desktop or narrow widths, but changing presentation does not create a parallel count or filter vocabulary: every selection still @@ -7408,10 +7418,11 @@ oversized requests clamp to the backend maximum of `100`. The outcome-first Patrol home consumes the existing typed attention contract; it does not create a second server lifecycle. `GET /api/ai/patrol/attention` with `filter=active` remains the source for current typed evidence and governed -action offers, while `filter=resolved` supplies receipt candidates. Frontend -presentation may call a resolved item handled only when the API-authored -`state` is `resolved` and `verificationState` is `succeeded`; it must preserve -pending, failed, unknown, and unavailable verification as non-receipts. The +action offers. `GET /api/ai/patrol/attention/receipts` is the canonical recent +work source for both autonomous Patrol actions and Operational Trust attention +actions. It admits only completed execution with a confirmed postcondition and +therefore never returns pending, failed, contradicted, inconclusive, rejected, +or unrelated-origin work as a receipt. The frontend may use action eligibility and approval fields to decide whether an item needs another operator decision under the effective autonomy level, but that projection grants no action authority and cannot override server planning, diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index f848fc079..c1b84efc2 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -778,6 +778,12 @@ genuine decisions, verified receipts, and secondary operational records; the shared primitive boundary owns keyboard focus, touch targets, responsive stacking, and visual consistency. Plan-locked paid-mode discovery is not a daily-page primitive and must not be added beside the effective mode. +The compact `Recently handled` list consumes server-authored Patrol work +receipts rather than filtering generic history in the browser. It may format +the canonical capability name and verification time with shared presentation +helpers, but it must preserve the server's verified-only membership, evidence +summary, newest-first order, empty state, and last-truthful-data behavior during +a refresh error. For Patrol, that includes the Open work description: it may use concise row-level guidance such as review evidence, approve a change, inspect automatic actions, or review verification results, but it must remain descriptive copy diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index b1bc33d66..76a6a170c 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -93,11 +93,16 @@ authority or a bypass around planning, feasibility, approval, dispatch, and verification. The `Recently handled` surface is proof-constrained. It may render a receipt -only for a resolved attention record whose verification state is `succeeded`, -and it describes the resolved record rather than inferring which offered action -ran. Pending, failed, unknown, or unavailable verification must never be styled -as handled work. Objective summary language similarly counts protection only -from active objectives whose server-authored coverage state is `covered`. +only from the server-filtered verified-work endpoint backed by canonical action +audits. That projection covers both autonomous `patrol` actions and governed +`operational_trust_attention` actions, applies origin plus confirmed +postcondition filtering before its display limit, and orders by verification +time newest first. The browser never reconstructs receipts from a paginated +resolved-attention or settled-action list. Pending, failed, contradicted, +inconclusive, rejected, unknown, unavailable, and unrelated-origin work must +never be styled as handled work. Objective summary language similarly counts +protection only from active objectives whose server-authored coverage state is +`covered`. The effective autonomy label and plain-language consequence stay visible, but the four-level selector is a secondary disclosure. A plan-locked daily operator diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index b5d732921..86c83cc18 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -2446,11 +2446,17 @@ Memory and SQLite preserve the same object through replay and reopen. Action audits are the durable source of truth for Patrol action continuity. The store exposes optional `ActionAuditOriginReader` and `PendingActionAuditReader` capabilities; origin lookup is scoped by org and -investigation identity, while pending reads are oldest-first. SQLite persists +investigation identity, while pending reads are oldest-first. The optional +`VerifiedActionAuditOriginReader` is the canonical receipt source: it applies +the trusted origin set and confirmed postcondition predicate before the bound, +returns newest verification first, and never asks a product client to scan a +generic settled-action page for proof. SQLite persists an absent origin as NULL, guards JSON-expression queries and indexes with `json_valid(origin_json)`, and keeps dedicated origin/state indexes so an old -empty or malformed value cannot reject otherwise valid audit rows. Memory and -SQLite implementations preserve the same ordering and clone semantics. +empty or malformed value cannot reject otherwise valid audit rows. Its +origin/verification/update index keeps the receipt read bounded as audit +history grows. Memory and SQLite implementations preserve the same filtering, +ordering, and clone semantics. Terminal audit persistence derives `VerificationOutcome` from the canonical execution result: no verifier is unknown, a configured verifier that did not run is unverified, a successful read-back is verified, and a failed read-back diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 27998b848..2783f66a4 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,30 +1,25 @@ { "version": 1, - "base_sha": "48fac73ff020491c1731c281d0c69b874c27f71b", - "verified_at": "2026-08-14T12:03:35Z", + "base_sha": "7fb39f65cf25a3fc822f5795abc99dd670e93a48", + "verified_at": "2026-08-15T07:42:09Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/AppLayout.tsx", - "frontend-modern/src/components/shared/mobileNavBarModel.ts", - "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", - "frontend-modern/src/pages/Actions.tsx", - "frontend-modern/src/utils/assistantPageContext.ts" + "frontend-modern/src/api/patrolAttention.ts", + "frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx", + "frontend-modern/src/features/patrol/patrolHomePresentation.ts" ], "content_sha256": { - "frontend-modern/src/AppLayout.tsx": "91e6aa7723872ec61437da7d3851fe82be89e707f53c66a3c3ff74fa7851671a", - "frontend-modern/src/components/shared/mobileNavBarModel.ts": "bccadf7522c9e7affa06ecebd816c8963295bd40ce2ac122a022d3662e1a5e7d", - "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "b6eaffbb9aa1e0b9e69a5245d0e2e4bce5b9c88df353d207db095189df6e82c1", - "frontend-modern/src/pages/Actions.tsx": "878397bb4571fc714684e35474acc6a60220498b4ac256c4bd57f39e41533ba5", - "frontend-modern/src/utils/assistantPageContext.ts": "7943a778ee4d4e1fb56c0a09778fffc8402d528da2e336713b1ff93e110acf6f" + "frontend-modern/src/api/patrolAttention.ts": "2038e240e586d096f289e477e05c0acdb9d33eb08fe5791dd8080666a43350df", + "frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx": "c996100b27f7101a2a64c742ff0c1602cb2017397afad22a3da5e8e768ac8798", + "frontend-modern/src/features/patrol/patrolHomePresentation.ts": "41991e1b605df792768237e488edf0d1f8c1190896e5be865dfbd5f24017e9aa" }, "routes": [ - "/patrol", - "/actions" + "/patrol" ], "viewports": [ { "width": 1280, - "height": 800 + "height": 720 }, { "width": 390, @@ -32,18 +27,17 @@ } ], "states": [ - "Patrol desktop navigation with one pending universal action", - "Patrol Activity history handoff with pending-review badge", - "Activity history open queue with Patrol selected in desktop navigation", - "Activity history open queue with Patrol selected in mobile navigation", - "Patrol and Activity history at 390 pixels without horizontal overflow" + "Recently handled populated with six newest server-filtered verified Patrol receipts", + "Refresh failure keeps the last truthful receipts visible with an explicit unavailable message", + "Refresh recovery clears the error and reloads the same six verified receipts", + "Activity history handoff remains adjacent to verified receipts", + "Phone-width receipt list wraps verification evidence without horizontal overflow" ], "interactions": [ - "opened Activity history from the Patrol page", - "confirmed the stable /actions route and Activity history browser title", - "confirmed no Actions destination exists in desktop or mobile navigation", - "confirmed Patrol remains selected while Activity history is open", - "returned to Patrol through mobile navigation", + "refreshed the populated Recently handled list", + "stopped the disposable server and refreshed to exercise the error state", + "confirmed six existing receipts remained visible during the refresh error", + "restored the healthy server and refreshed to clear the error", "resized to 390 by 844 and confirmed document width stayed at 390" ] } diff --git a/frontend-modern/src/api/__tests__/patrolAttention.test.ts b/frontend-modern/src/api/__tests__/patrolAttention.test.ts index fb5296015..2cb6eb69a 100644 --- a/frontend-modern/src/api/__tests__/patrolAttention.test.ts +++ b/frontend-modern/src/api/__tests__/patrolAttention.test.ts @@ -10,6 +10,7 @@ import { getPatrolAttentionDetail, getPatrolAttentionEvidence, getPatrolAttentionSummary, + getPatrolWorkReceipts, planPatrolAttentionAction, suppressPatrolAttention, unacknowledgePatrolAttention, @@ -45,6 +46,11 @@ describe('Patrol attention API', () => { ); }); + it('uses the server-filtered verified work receipt projection', async () => { + await getPatrolWorkReceipts(6); + expect(fetchMock).toHaveBeenLastCalledWith('/api/ai/patrol/attention/receipts?limit=6'); + }); + it('uses canonical item-scoped lifecycle mutations with bounded suppression input', async () => { await acknowledgePatrolAttention('record/one'); expect(fetchMock).toHaveBeenLastCalledWith( diff --git a/frontend-modern/src/api/patrolAttention.ts b/frontend-modern/src/api/patrolAttention.ts index 7ef8a416a..b7b5347aa 100644 --- a/frontend-modern/src/api/patrolAttention.ts +++ b/frontend-modern/src/api/patrolAttention.ts @@ -97,6 +97,26 @@ export interface AttentionEvidenceResponse { retained: boolean; } +export interface PatrolWorkReceipt { + actionId: string; + resourceId: string; + resourceName: string; + resourceType?: string; + capabilityName: string; + verifiedAt: string; + verificationSummary: string; + evidenceClass: 'none' | 'agent_attested' | 'independent'; + originSurface: 'patrol' | 'operational_trust_attention'; + findingId?: string; + operationalRecordId?: string; +} + +export interface PatrolWorkReceiptListResponse { + data: PatrolWorkReceipt[]; + count: number; + limit: number; +} + export interface AttentionMutationResponse { success: boolean; } @@ -118,6 +138,13 @@ export async function getPatrolAttentionSummary(): Promise { return apiFetchJSON('/api/ai/patrol/attention/summary'); } +export async function getPatrolWorkReceipts(limit = 6): Promise { + const search = new URLSearchParams({ limit: String(limit) }); + return apiFetchJSON( + `/api/ai/patrol/attention/receipts?${search.toString()}`, + ); +} + export async function getPatrolAttentionDetail(itemId: string): Promise { return apiFetchJSON( `/api/ai/patrol/attention/${encodeURIComponent(itemId)}`, diff --git a/frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx b/frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx index d0312a3df..69c704e01 100644 --- a/frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx +++ b/frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx @@ -1,10 +1,10 @@ import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js'; import CheckCircleIcon from 'lucide-solid/icons/circle-check'; import RefreshIcon from 'lucide-solid/icons/refresh-cw'; -import { getPatrolAttention, type AttentionItem } from '@/api/patrolAttention'; +import { getPatrolWorkReceipts, type PatrolWorkReceipt } from '@/api/patrolAttention'; import { Button } from '@/components/shared/Button'; +import { formatActionName } from '@/features/actions/actionPresentation'; import { formatRelativeTime } from '@/utils/format'; -import { getVerifiedPatrolReceiptSummary, isVerifiedPatrolReceipt } from './patrolHomePresentation'; const RECEIPT_LIMIT = 6; @@ -12,17 +12,15 @@ const formatRecentWorkError = (error: unknown): string => error instanceof Error ? error.message : 'Verified work could not be loaded.'; export function PatrolRecentWorkPanel() { - const [items, setItems] = createSignal([]); + const [items, setItems] = createSignal([]); const [loading, setLoading] = createSignal(true); const [error, setError] = createSignal(''); - const receipts = createMemo(() => - items().filter(isVerifiedPatrolReceipt).slice(0, RECEIPT_LIMIT), - ); + const receipts = createMemo(() => items().slice(0, RECEIPT_LIMIT)); const load = async (quiet = false) => { if (!quiet) setLoading(true); try { - const response = await getPatrolAttention('resolved', 1, 50); + const response = await getPatrolWorkReceipts(RECEIPT_LIMIT); setItems(response.data); setError(''); } catch (cause) { @@ -116,15 +114,15 @@ export function PatrolRecentWorkPanel() { Verified - {formatRelativeTime(item.lastObservedAt, { compact: true })} + {formatRelativeTime(item.verifiedAt, { compact: true })} -

{item.title}

-

- {getVerifiedPatrolReceiptSummary(item)} +

+ {formatActionName(item.capabilityName)} verified

+

{item.verificationSummary}

- {item.subjectResourceName} + {item.resourceName}

diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolRecentWorkPanel.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolRecentWorkPanel.test.tsx index 53334c6a7..bfdece216 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolRecentWorkPanel.test.tsx +++ b/frontend-modern/src/features/patrol/__tests__/PatrolRecentWorkPanel.test.tsx @@ -1,93 +1,55 @@ import { cleanup, render, screen, waitFor } from '@solidjs/testing-library'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { AttentionItem, AttentionListResponse } from '@/api/patrolAttention'; +import type { PatrolWorkReceipt, PatrolWorkReceiptListResponse } from '@/api/patrolAttention'; -const apiMocks = vi.hoisted(() => ({ getList: vi.fn() })); +const apiMocks = vi.hoisted(() => ({ getReceipts: vi.fn() })); vi.mock('@/api/patrolAttention', async (importOriginal) => { const original = await importOriginal(); return { ...original, - getPatrolAttention: (...args: unknown[]) => apiMocks.getList(...args), + getPatrolWorkReceipts: (...args: unknown[]) => apiMocks.getReceipts(...args), }; }); import { PatrolRecentWorkPanel } from '../PatrolRecentWorkPanel'; -const receipt = (overrides: Partial = {}): AttentionItem => ({ - id: 'receipt-1', - operationalRecordId: 'record-1', - subjectResourceId: 'docker:host/service/jellyfin', - subjectResourceName: 'Jellyfin', - kind: 'availability', - title: 'Jellyfin playback restored', - plainLanguageSummary: 'Playback recovered.', - severity: 'warning', - state: 'resolved', - firstObservedAt: '2026-08-14T07:00:00Z', - lastObservedAt: '2026-08-14T07:05:00Z', - evidenceFreshness: 'fresh', - evidenceCompleteness: 'complete', - relatedResources: [], - availableActions: [ - { - targetResourceId: 'docker:host/service/jellyfin', - capability: 'restart_service', - kind: 'restart', - label: 'Restart Jellyfin', - mode: 'execute', - risk: 'low', - approval: 'not-required', - eligibility: 'eligible', - reasons: [], - evidenceIds: ['evidence-1'], - expectedPostcondition: 'Playback health checks return to normal.', - verificationPolicy: 'availability', - requiresApproval: false, - }, - ], - verificationState: 'succeeded', +const receipt = (overrides: Partial = {}): PatrolWorkReceipt => ({ + actionId: 'action-1', + resourceId: 'docker:host/service/jellyfin', + resourceName: 'Jellyfin', + capabilityName: 'restart', + verifiedAt: '2026-08-14T07:05:00Z', + verificationSummary: 'Playback recovered.', + evidenceClass: 'agent_attested', + originSurface: 'patrol', + findingId: 'finding-1', ...overrides, }); -const response = (items: AttentionItem[]): AttentionListResponse => ({ +const response = (items: PatrolWorkReceipt[]): PatrolWorkReceiptListResponse => ({ data: items, - summary: { - activeCount: 0, - openCount: 0, - acknowledgedCount: 0, - suppressedCount: 0, - uncertainCount: 0, - resolvedCount: items.length, - calm: true, - coverageState: 'current', - evaluatedAt: '2026-08-14T07:05:00Z', - }, - meta: { page: 1, limit: 50, total: items.length, totalPages: items.length ? 1 : 0 }, + count: items.length, + limit: 6, }); describe('PatrolRecentWorkPanel', () => { - beforeEach(() => apiMocks.getList.mockReset()); + beforeEach(() => apiMocks.getReceipts.mockReset()); afterEach(cleanup); - it('shows only successfully verified resolved work as receipts', async () => { - apiMocks.getList.mockResolvedValue( - response([ - receipt(), - receipt({ id: 'failed', verificationState: 'failed' }), - receipt({ id: 'open', state: 'open' }), - ]), - ); + it('shows server-authored verified Patrol work receipts', async () => { + apiMocks.getReceipts.mockResolvedValue(response([receipt()])); render(() => ); expect(await screen.findByText('Playback recovered.')).toBeInTheDocument(); expect(screen.getAllByText('Verified')).toHaveLength(1); - expect(apiMocks.getList).toHaveBeenCalledWith('resolved', 1, 50); + expect(screen.getByText('Restart verified')).toBeInTheDocument(); + expect(apiMocks.getReceipts).toHaveBeenCalledWith(6); }); it('does not imply successful work when there is no verified receipt', async () => { - apiMocks.getList.mockResolvedValue(response([receipt({ verificationState: 'unknown' })])); + apiMocks.getReceipts.mockResolvedValue(response([])); render(() => ); @@ -98,8 +60,8 @@ describe('PatrolRecentWorkPanel', () => { }); it('keeps the last truthful receipts visible if a background refresh fails', async () => { - apiMocks.getList.mockResolvedValueOnce(response([receipt()])); - apiMocks.getList.mockRejectedValueOnce(new Error('relay unavailable')); + apiMocks.getReceipts.mockResolvedValueOnce(response([receipt()])); + apiMocks.getReceipts.mockRejectedValueOnce(new Error('relay unavailable')); render(() => ); await screen.findByText('Playback recovered.'); diff --git a/frontend-modern/src/features/patrol/__tests__/patrolHomePresentation.test.ts b/frontend-modern/src/features/patrol/__tests__/patrolHomePresentation.test.ts index 1684e83ee..95d4ccdd2 100644 --- a/frontend-modern/src/features/patrol/__tests__/patrolHomePresentation.test.ts +++ b/frontend-modern/src/features/patrol/__tests__/patrolHomePresentation.test.ts @@ -4,8 +4,6 @@ import type { AttentionItem } from '@/api/patrolAttention'; import { getPatrolAttentionDecisionReason, getPatrolObjectiveProtectionSummary, - getVerifiedPatrolReceiptSummary, - isVerifiedPatrolReceipt, partitionPatrolAttention, } from '../patrolHomePresentation'; @@ -146,18 +144,4 @@ describe('patrol home presentation', () => { tone: 'warning', }); }); - - it('creates receipts only from resolved records with successful verification', () => { - const resolved = attentionItem({ - state: 'resolved', - verificationState: 'succeeded', - plainLanguageSummary: 'Playback health checks returned to normal.', - }); - - expect(isVerifiedPatrolReceipt(resolved)).toBe(true); - expect(isVerifiedPatrolReceipt(attentionItem({ state: 'resolved' }))).toBe(false); - expect(getVerifiedPatrolReceiptSummary(resolved)).toBe( - 'Playback health checks returned to normal.', - ); - }); }); diff --git a/frontend-modern/src/features/patrol/patrolHomePresentation.ts b/frontend-modern/src/features/patrol/patrolHomePresentation.ts index 40b373435..483d024f8 100644 --- a/frontend-modern/src/features/patrol/patrolHomePresentation.ts +++ b/frontend-modern/src/features/patrol/patrolHomePresentation.ts @@ -176,11 +176,3 @@ export function getPatrolObjectiveProtectionSummary( tone: 'warning', }; } - -export function isVerifiedPatrolReceipt(item: AttentionItem): boolean { - return item.state === 'resolved' && item.verificationState === 'succeeded'; -} - -export function getVerifiedPatrolReceiptSummary(item: AttentionItem): string { - return item.plainLanguageSummary; -} diff --git a/internal/api/attention_handlers.go b/internal/api/attention_handlers.go index 042ba14db..d4134ca2c 100644 --- a/internal/api/attention_handlers.go +++ b/internal/api/attention_handlers.go @@ -108,6 +108,8 @@ func (h *AttentionHandlers) HandleAttention(w http.ResponseWriter, r *http.Reque h.handleAttentionList(w, r) case path == "/summary": h.handleAttentionSummary(w, r) + case path == "/receipts": + h.handleAttentionReceipts(w, r) case strings.Contains(path, "/evidence/"): h.handleAttentionEvidence(w, r, path) case strings.HasPrefix(path, "/"): diff --git a/internal/api/attention_handlers_test.go b/internal/api/attention_handlers_test.go index 161a97f81..b62621423 100644 --- a/internal/api/attention_handlers_test.go +++ b/internal/api/attention_handlers_test.go @@ -13,11 +13,118 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/ai" "github.com/rcourtman/pulse-go-rewrite/internal/alerts" + "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/internal/monitoring" "github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust" + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" ) +func TestAttentionHandlersReceiptsUseVerifiedPatrolActionsBeforeLimit(t *testing.T) { + now := time.Date(2026, 8, 15, 8, 0, 0, 0, time.UTC) + resources := NewResourceHandlers(&config.Config{DataPath: t.TempDir()}) + store, err := resources.getStore("default") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = resources.CloseStores() }) + for _, record := range []unifiedresources.ActionAuditRecord{ + attentionReceiptTestRecord("patrol-old", patrolActionOriginSurface, now.Add(-time.Minute), true), + attentionReceiptTestRecord("attention-new", operationalTrustActionOriginSurface, now, true), + attentionReceiptTestRecord("assistant-newer", "assistant", now.Add(time.Minute), true), + attentionReceiptTestRecord("patrol-unverified", patrolActionOriginSurface, now.Add(2*time.Minute), false), + } { + if err := store.RecordActionAudit(record); err != nil { + t.Fatalf("RecordActionAudit(%s): %v", record.ID, err) + } + } + handler := &AttentionHandlers{resources: resources} + request := httptest.NewRequest( + http.MethodGet, + "/api/ai/patrol/attention/receipts?limit=6", + nil, + ) + response := httptest.NewRecorder() + handler.HandleAttention(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) + } + var payload patrolWorkReceiptListResponse + if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload.Count != 2 || payload.Limit != 6 || len(payload.Data) != 2 { + t.Fatalf("payload = %#v", payload) + } + if payload.Data[0].ActionID != "attention-new" || + payload.Data[1].ActionID != "patrol-old" { + t.Fatalf("receipt order = %#v", payload.Data) + } + for _, receipt := range payload.Data { + if receipt.VerificationSummary == "" || receipt.EvidenceClass != unifiedresources.ActionEvidenceAgentAttested { + t.Fatalf("receipt omitted verification proof: %#v", receipt) + } + } + + limitedRequest := httptest.NewRequest( + http.MethodGet, + "/api/ai/patrol/attention/receipts?limit=1", + nil, + ) + limitedResponse := httptest.NewRecorder() + handler.HandleAttention(limitedResponse, limitedRequest) + var limited patrolWorkReceiptListResponse + if err := json.Unmarshal(limitedResponse.Body.Bytes(), &limited); err != nil { + t.Fatal(err) + } + if len(limited.Data) != 1 || limited.Data[0].ActionID != "attention-new" { + t.Fatalf("limited receipts = %#v", limited.Data) + } +} + +func TestAttentionHandlersReceiptsRejectInvalidLimit(t *testing.T) { + handler := &AttentionHandlers{} + request := httptest.NewRequest( + http.MethodGet, + "/api/ai/patrol/attention/receipts?limit=51", + nil, + ) + response := httptest.NewRecorder() + handler.HandleAttention(response, request) + if response.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", response.Code, response.Body.String()) + } +} + +func attentionReceiptTestRecord( + id string, + surface string, + updatedAt time.Time, + verified bool, +) unifiedresources.ActionAuditRecord { + verification := &unifiedresources.ActionVerificationResult{ + Ran: true, Success: verified, RanAt: updatedAt, + } + outcome := unifiedresources.VerificationOutcome{Status: unifiedresources.VerificationFailed} + if verified { + outcome = unifiedresources.VerificationOutcome{ + Status: unifiedresources.VerificationVerified, + EvidenceSummary: "Service healthy after restart.", + } + } + return unifiedresources.ActionAuditRecord{ + ID: id, CreatedAt: updatedAt.Add(-time.Second), UpdatedAt: updatedAt, + State: unifiedresources.ActionStateCompleted, + Request: unifiedresources.ActionRequest{ + RequestID: id + "-request", ResourceID: "app-container:test", CapabilityName: "restart", RequestedBy: "pulse_patrol", + }, + Plan: unifiedresources.ActionPlan{ActionID: id, RequestID: id + "-request", Allowed: true}, + Origin: &unifiedresources.ActionOrigin{Surface: surface, FindingID: id + "-finding"}, + Result: &unifiedresources.ExecutionResult{Success: true, Verification: verification}, + VerificationOutcome: outcome, + } +} + func TestAttentionHandlersListUsesCanonicalCountAndFilters(t *testing.T) { now := time.Date(2026, 7, 19, 6, 0, 0, 0, time.UTC) handler := &AttentionHandlers{ diff --git a/internal/api/attention_receipts.go b/internal/api/attention_receipts.go new file mode 100644 index 000000000..654d14971 --- /dev/null +++ b/internal/api/attention_receipts.go @@ -0,0 +1,152 @@ +package api + +import ( + "net/http" + "strconv" + "strings" + "time" + + "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" + "github.com/rcourtman/pulse-go-rewrite/internal/utils" + "github.com/rs/zerolog/log" +) + +const ( + defaultPatrolReceiptLimit = 6 + maxPatrolReceiptLimit = 50 +) + +var patrolReceiptOriginSurfaces = []string{ + patrolActionOriginSurface, + operationalTrustActionOriginSurface, +} + +type patrolWorkReceipt struct { + ActionID string `json:"actionId"` + ResourceID string `json:"resourceId"` + ResourceName string `json:"resourceName"` + ResourceType unifiedresources.ResourceType `json:"resourceType,omitempty"` + CapabilityName string `json:"capabilityName"` + VerifiedAt time.Time `json:"verifiedAt"` + VerificationSummary string `json:"verificationSummary"` + EvidenceClass unifiedresources.ActionEvidenceClass `json:"evidenceClass"` + OriginSurface string `json:"originSurface"` + FindingID string `json:"findingId,omitempty"` + OperationalRecordID string `json:"operationalRecordId,omitempty"` +} + +type patrolWorkReceiptListResponse struct { + Data []patrolWorkReceipt `json:"data"` + Count int `json:"count"` + Limit int `json:"limit"` +} + +func (h *AttentionHandlers) handleAttentionReceipts(w http.ResponseWriter, r *http.Request) { + limit, ok := parsePatrolReceiptLimit(w, r) + if !ok { + return + } + if h == nil || h.resources == nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "patrol_receipts_unavailable", "Verified Patrol work is unavailable.", nil) + return + } + orgID := GetOrgID(r.Context()) + store, err := h.resources.getStore(orgID) + if err != nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "patrol_receipts_unavailable", "Verified Patrol work is unavailable.", nil) + return + } + reader, ok := store.(unifiedresources.VerifiedActionAuditOriginReader) + if !ok { + writeErrorResponse(w, http.StatusServiceUnavailable, "patrol_receipts_unavailable", "Verified Patrol work is unavailable.", nil) + return + } + records, err := reader.GetVerifiedActionAuditsByOrigins(patrolReceiptOriginSurfaces, limit) + if err != nil { + writeErrorResponse(w, http.StatusServiceUnavailable, "patrol_receipts_unavailable", "Verified Patrol work is unavailable.", nil) + return + } + + registry, _ := h.resources.buildRegistry(orgID) + receipts := make([]patrolWorkReceipt, 0, len(records)) + for _, record := range records { + if receipt, projected := projectPatrolWorkReceipt(record, registry); projected { + receipts = append(receipts, receipt) + } + } + response := patrolWorkReceiptListResponse{Data: receipts, Count: len(receipts), Limit: limit} + if err := utils.WriteJSONResponse(w, response); err != nil { + log.Error().Err(err).Msg("Failed to serialize verified Patrol work receipts") + } +} + +func parsePatrolReceiptLimit(w http.ResponseWriter, r *http.Request) (int, bool) { + raw := strings.TrimSpace(r.URL.Query().Get("limit")) + if raw == "" { + return defaultPatrolReceiptLimit, true + } + limit, err := strconv.Atoi(raw) + if err != nil || limit < 1 || limit > maxPatrolReceiptLimit { + writeErrorResponse( + w, + http.StatusBadRequest, + "invalid_patrol_receipt_limit", + "Receipt limit must be between 1 and 50.", + map[string]string{"limit": strconv.Itoa(maxPatrolReceiptLimit)}, + ) + return 0, false + } + return limit, true +} + +func projectPatrolWorkReceipt( + record unifiedresources.ActionAuditRecord, + registry *unifiedresources.ResourceRegistry, +) (patrolWorkReceipt, bool) { + if record.Origin == nil { + return patrolWorkReceipt{}, false + } + originSurface := strings.TrimSpace(record.Origin.Surface) + if originSurface != patrolActionOriginSurface && + originSurface != operationalTrustActionOriginSurface { + return patrolWorkReceipt{}, false + } + truth := unifiedresources.CanonicalActionResultV2(record) + if record.State != unifiedresources.ActionStateCompleted || + truth.Execution.Status != unifiedresources.ActionExecutionSucceeded || + truth.Verification.Status != unifiedresources.ActionVerificationConfirmed { + return patrolWorkReceipt{}, false + } + resourceID := strings.TrimSpace(record.Request.ResourceID) + resourceName := resourceID + var resourceType unifiedresources.ResourceType + if registry != nil { + if resource, found := registry.Get(resourceID); found { + resourceName = strings.TrimSpace(resource.Name) + resourceType = resource.Type + } + } + if resourceName == "" { + resourceName = resourceID + } + summary := strings.TrimSpace(truth.Verification.Summary) + if summary == "" { + summary = strings.TrimSpace(record.VerificationOutcome.EvidenceSummary) + } + if summary == "" { + summary = "Patrol confirmed the expected outcome after the change." + } + return patrolWorkReceipt{ + ActionID: record.ID, + ResourceID: resourceID, + ResourceName: resourceName, + ResourceType: resourceType, + CapabilityName: strings.TrimSpace(record.Request.CapabilityName), + VerifiedAt: record.UpdatedAt.UTC(), + VerificationSummary: summary, + EvidenceClass: truth.Verification.EvidenceClass, + OriginSurface: originSurface, + FindingID: strings.TrimSpace(record.Origin.FindingID), + OperationalRecordID: strings.TrimSpace(record.Origin.OperationalRecordID), + }, true +} diff --git a/internal/unifiedresources/store.go b/internal/unifiedresources/store.go index 1a4dcbd63..0040bc837 100644 --- a/internal/unifiedresources/store.go +++ b/internal/unifiedresources/store.go @@ -112,6 +112,15 @@ type OperationalActionAuditOriginBatchReader interface { GetLatestActionAuditsByOperationalRecords(surface string, operationalRecordIDs []string) (map[string]ActionAuditRecord, error) } +// VerifiedActionAuditOriginReader owns the bounded, newest-first projection of +// actions whose postcondition was actually confirmed. Product surfaces such as +// Patrol receipts must filter origin and verification before applying their +// display limit; scanning a generic settled-action page can silently hide real +// verified work behind unrelated history. +type VerifiedActionAuditOriginReader interface { + GetVerifiedActionAuditsByOrigins(surfaces []string, limit int) ([]ActionAuditRecord, error) +} + // PendingActionAuditReader owns the indexed operator queue for canonical // actions awaiting a decision. Mobile and desktop clients must not reconstruct // this queue from the retired command-approval store. @@ -734,6 +743,13 @@ func (s *SQLiteResourceStore) migrateActionAuditsSchema() error { `); err != nil { return fmt.Errorf("create action audit origin operational record index: %w", err) } + if _, err := s.db.Exec(` + CREATE INDEX IF NOT EXISTS idx_action_audits_origin_verification_updated + ON action_audits(json_extract(origin_json, '$.surface'), json_extract(verification_outcome_json, '$.status'), updated_at DESC, created_at DESC) + WHERE json_valid(origin_json) AND json_valid(verification_outcome_json) + `); err != nil { + return fmt.Errorf("create verified action audit origin index: %w", err) + } if _, err := s.db.Exec(` CREATE INDEX IF NOT EXISTS idx_action_audits_state_updated ON action_audits(state, updated_at ASC, created_at ASC) @@ -2432,6 +2448,58 @@ func (s *SQLiteResourceStore) GetLatestActionAuditsByOperationalRecords( return result, nil } +func (s *SQLiteResourceStore) GetVerifiedActionAuditsByOrigins( + surfaces []string, + limit int, +) ([]ActionAuditRecord, error) { + surfaces = uniqueStrings(surfaces) + if len(surfaces) == 0 { + return []ActionAuditRecord{}, nil + } + if limit < 1 || limit > 50 { + return nil, fmt.Errorf("verified action audit limit must be between 1 and 50") + } + placeholders := make([]string, len(surfaces)) + args := make([]any, 0, len(surfaces)+1) + for index, surface := range surfaces { + placeholders[index] = "?" + args = append(args, surface) + } + args = append(args, string(VerificationVerified), limit) + rows, err := s.db.Query(` + SELECT id, action_id, request_id, created_at, updated_at, state, decision_revision, request_json, plan_json, approvals_json, result_json, verification_outcome_json, origin_json + FROM action_audits + WHERE state = 'completed' + AND json_valid(origin_json) + AND json_extract(origin_json, '$.surface') IN (`+strings.Join(placeholders, ",")+`) + AND json_valid(verification_outcome_json) + AND json_extract(verification_outcome_json, '$.status') = ? + ORDER BY updated_at DESC, created_at DESC, id ASC + LIMIT ? + `, args...) + if err != nil { + return nil, fmt.Errorf("query verified action audits by origin: %w", err) + } + defer rows.Close() + records := make([]ActionAuditRecord, 0, limit) + for rows.Next() { + record, scanErr := scanActionAuditRecord(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan verified action audit by origin: %w", scanErr) + } + truth := CanonicalActionResultV2(record) + if truth.Execution.Status != ActionExecutionSucceeded || + truth.Verification.Status != ActionVerificationConfirmed { + continue + } + records = append(records, record) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate verified action audits by origin: %w", err) + } + return records, nil +} + func (s *SQLiteResourceStore) GetPendingActionAudits(limit int) ([]ActionAuditRecord, error) { if limit <= 0 || limit > 500 { limit = 100 @@ -3750,6 +3818,53 @@ func (m *MemoryStore) GetLatestActionAuditsByOperationalRecords( return result, nil } +func (m *MemoryStore) GetVerifiedActionAuditsByOrigins( + surfaces []string, + limit int, +) ([]ActionAuditRecord, error) { + surfaces = uniqueStrings(surfaces) + if len(surfaces) == 0 { + return []ActionAuditRecord{}, nil + } + if limit < 1 || limit > 50 { + return nil, fmt.Errorf("verified action audit limit must be between 1 and 50") + } + wanted := make(map[string]struct{}, len(surfaces)) + for _, surface := range surfaces { + wanted[surface] = struct{}{} + } + m.mu.RLock() + defer m.mu.RUnlock() + records := make([]ActionAuditRecord, 0, limit) + for _, record := range m.actionAudits { + if record.State != ActionStateCompleted || record.Origin == nil { + continue + } + if _, found := wanted[strings.TrimSpace(record.Origin.Surface)]; !found { + continue + } + truth := CanonicalActionResultV2(record) + if truth.Execution.Status != ActionExecutionSucceeded || + truth.Verification.Status != ActionVerificationConfirmed { + continue + } + records = append(records, cloneActionAuditRecordForRead(record)) + } + sort.SliceStable(records, func(i, j int) bool { + if !records[i].UpdatedAt.Equal(records[j].UpdatedAt) { + return records[i].UpdatedAt.After(records[j].UpdatedAt) + } + if !records[i].CreatedAt.Equal(records[j].CreatedAt) { + return records[i].CreatedAt.After(records[j].CreatedAt) + } + return records[i].ID < records[j].ID + }) + if len(records) > limit { + records = records[:limit] + } + return records, nil +} + func (m *MemoryStore) GetPendingActionAudits(limit int) ([]ActionAuditRecord, error) { m.mu.RLock() defer m.mu.RUnlock() diff --git a/internal/unifiedresources/store_test.go b/internal/unifiedresources/store_test.go index d708ac3b1..49e68619d 100644 --- a/internal/unifiedresources/store_test.go +++ b/internal/unifiedresources/store_test.go @@ -3145,6 +3145,10 @@ func TestActionAuditOriginReaderReturnsLatestTransition(t *testing.T) { if !ok { t.Fatalf("%T does not implement OperationalActionAuditOriginBatchReader", store) } + verifiedReader, ok := store.(VerifiedActionAuditOriginReader) + if !ok { + t.Fatalf("%T does not implement VerifiedActionAuditOriginReader", store) + } now := time.Now().UTC() for _, record := range []ActionAuditRecord{ { @@ -3166,6 +3170,25 @@ func TestActionAuditOriginReaderReturnsLatestTransition(t *testing.T) { ProposalID: "prop-new", OperationalRecordID: "operational-1", EvidenceIDs: []string{"evidence-c"}, }, + Result: &ExecutionResult{ + Success: true, + Verification: &ActionVerificationResult{ + Ran: true, Success: true, RanAt: now, + }, + }, + VerificationOutcome: VerificationOutcome{Status: VerificationVerified}, + }, + { + ID: "act-unrelated", CreatedAt: now.Add(time.Second), UpdatedAt: now.Add(time.Second), State: ActionStateCompleted, + Request: ActionRequest{RequestID: "prop-unrelated", ResourceID: "vm:99", CapabilityName: "restart", RequestedBy: "operator"}, + Plan: ActionPlan{ActionID: "act-unrelated", RequestID: "prop-unrelated", Allowed: true}, + Origin: &ActionOrigin{Surface: "assistant"}, + Result: &ExecutionResult{ + Success: true, + Verification: &ActionVerificationResult{ + Ran: true, Success: true, RanAt: now.Add(time.Second), + }, + }, VerificationOutcome: VerificationOutcome{Status: VerificationVerified}, }, } { @@ -3194,6 +3217,13 @@ func TestActionAuditOriginReaderReturnsLatestTransition(t *testing.T) { if err != nil || len(batch) != 1 || batch["operational-1"].ID != "act-new" { t.Fatalf("GetLatestActionAuditsByOperationalRecords: batch=%#v err=%v", batch, err) } + verified, err := verifiedReader.GetVerifiedActionAuditsByOrigins( + []string{"operational_trust_attention", "patrol"}, + 6, + ) + if err != nil || len(verified) != 1 || verified[0].ID != "act-new" { + t.Fatalf("GetVerifiedActionAuditsByOrigins: records=%#v err=%v", verified, err) + } }) } }