Show verified Patrol work receipts

Contract-Neutral: Patrol receipt API does not change agent lifecycle or storage recovery contracts
This commit is contained in:
rcourtman 2026-08-15 08:48:49 +01:00
parent 7fb39f65cf
commit d3a345bd66
16 changed files with 533 additions and 136 deletions

View file

@ -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,

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"
]
}

View file

@ -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(

View file

@ -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<AttentionSummary> {
return apiFetchJSON<AttentionSummary>('/api/ai/patrol/attention/summary');
}
export async function getPatrolWorkReceipts(limit = 6): Promise<PatrolWorkReceiptListResponse> {
const search = new URLSearchParams({ limit: String(limit) });
return apiFetchJSON<PatrolWorkReceiptListResponse>(
`/api/ai/patrol/attention/receipts?${search.toString()}`,
);
}
export async function getPatrolAttentionDetail(itemId: string): Promise<AttentionItemDetail> {
return apiFetchJSON<AttentionItemDetail>(
`/api/ai/patrol/attention/${encodeURIComponent(itemId)}`,

View file

@ -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<AttentionItem[]>([]);
const [items, setItems] = createSignal<PatrolWorkReceipt[]>([]);
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
</span>
<span class="text-xs text-muted">
{formatRelativeTime(item.lastObservedAt, { compact: true })}
{formatRelativeTime(item.verifiedAt, { compact: true })}
</span>
</div>
<p class="mt-1 text-sm font-semibold text-base-content">{item.title}</p>
<p class="mt-1 text-xs leading-5 text-muted">
{getVerifiedPatrolReceiptSummary(item)}
<p class="mt-1 text-sm font-semibold text-base-content">
{formatActionName(item.capabilityName)} verified
</p>
<p class="mt-1 text-xs leading-5 text-muted">{item.verificationSummary}</p>
<p class="mt-1 truncate text-xs font-medium text-base-content">
{item.subjectResourceName}
{item.resourceName}
</p>
</div>
</li>

View file

@ -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<typeof import('@/api/patrolAttention')>();
return {
...original,
getPatrolAttention: (...args: unknown[]) => apiMocks.getList(...args),
getPatrolWorkReceipts: (...args: unknown[]) => apiMocks.getReceipts(...args),
};
});
import { PatrolRecentWorkPanel } from '../PatrolRecentWorkPanel';
const receipt = (overrides: Partial<AttentionItem> = {}): 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> = {}): 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(() => <PatrolRecentWorkPanel />);
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(() => <PatrolRecentWorkPanel />);
@ -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(() => <PatrolRecentWorkPanel />);
await screen.findByText('Playback recovered.');

View file

@ -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.',
);
});
});

View file

@ -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;
}

View file

@ -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, "/"):

View file

@ -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{

View file

@ -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
}

View file

@ -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()

View file

@ -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)
}
})
}
}