Build canonical Operational Trust action loop

Refs #1034
This commit is contained in:
rcourtman 2026-07-19 05:45:58 +01:00
parent 69533c7afb
commit cd75722da3
28 changed files with 2505 additions and 94 deletions

View file

@ -0,0 +1,208 @@
# Operational Trust: Governed Docker Restart
Date: 2026-07-19
Specification:
`docs/release-control/v6/internal/OPERATIONAL_TRUST_IMPLEMENTATION_SPEC.md`
Phase: 5, governed actions and verification
Candidate: `protection-posture-attention-queue`
## Decision
The first Operational Trust mutation is the existing declared Docker container
`restart` capability, offered only from a canonical
`docker-container-health` operational record.
Pulse does not create a Patrol-local executor. The attention surface projects
eligibility into the existing action lifecycle, then the shared Actions review
owns approval, execution, audit, receipt, and verification. The action origin
binds the exact operational record and evidence IDs. The stable request ID is
derived from the record and capability, so repeated planning and execution
replay the same durable action rather than sending the mutation twice.
The offer fails closed unless all of the following remain true:
1. the lifecycle record is open or acknowledged
2. every contributing evidence envelope is fresh, complete, confirmed,
sufficiently permitted, and bound unambiguously to the same canonical
container
3. the current unified resource still declares the exact `restart` capability,
admin approval floor, and Docker lifecycle handler
4. the live executor reports the capability ready
5. the current operator can plan, approve, and execute the action
An existing origin-bound action remains reviewable as durable history after the
operational record resolves, subject to current authorization.
## Verification Boundary
The Docker executor performs its existing typed readback after the provider
accepts the restart. That readback may confirm or contradict the action
postcondition, and its trust class remains visible. A successful command or
confirmed running-state readback does not close the operational issue.
Only a later detector-owned, fresh healthy observation may transition the
canonical operational record to resolved. The Patrol detail therefore
distinguishes:
- pending execution or verification
- confirmed restart postcondition, with the issue still open
- contradicted postcondition, with the issue still open
- inconclusive verification, with the issue still open
Provider timeout, callback loss, and server restart retain the durable dispatch
attempt and reconcile a correlated receipt without redispatch.
## User Lens
Operator job:
> This container is unhealthy. Show me the one safe action Pulse can actually
> perform, let me review it before anything is sent, and tell me whether it
> worked without pretending the original health problem is fixed.
Live deterministic exercise:
1. Open Patrol from the monitor-first shell.
2. Select the unhealthy API container.
3. Read the impact, fresh evidence posture, bounded restart postcondition, and
explicit approval boundary in the selected detail.
4. Open the shared governed-action review.
5. Expand planning-time policy evidence.
6. Approve, perform a second deliberate Run action, and close the review.
7. Read either confirmed or contradicted verification beside the still-open
operational issue.
Distance to the answer is one Patrol navigation, one item selection, and one
action review. The default queue carries no mutation button. The selected
detail contains one bounded action; exact policy, approval, delivery, and
verification forensics remain in the shared review.
Keep / demote / cut:
- Keep one eligible safe action beside the selected issue.
- Keep the expected postcondition and approval warning before opening review.
- Keep execution versus verification truth explicit after the action.
- Demote policy authorities, exact audit identity, delivery receipt, and raw
verification evidence to the shared review.
- Cut actions for stale, partial, permission-limited, ambiguous, unsupported,
or currently unavailable evidence.
- Cut provider success as a synonym for operational recovery.
- Cut duplicate execute controls and any Patrol-local action lifecycle.
Vocabulary:
- `Review and approve` states the next operator decision.
- `Review action` opens durable history after a plan exists.
- `Postcondition confirmed` says what the action proved; it does not say the
service is healthy.
- `Issue stays open until fresh health evidence` explains why the queue item
remains.
Verdict: `product`. The flow is bounded, deliberate, auditable, responsive,
keyboard operable, and honest about both failed verification and the separate
detector-owned recovery boundary.
## User and Comparative Evidence
- [#1034: Docker container start/stop/restart option](https://github.com/rcourtman/Pulse/issues/1034)
directly requests a container restart control in Pulse. Operational Trust
places it in selected issue context rather than adding an unaudited hover
mutation.
- [#1564: failed one-click container update can leave a service stopped](https://github.com/rcourtman/Pulse/issues/1564)
demonstrates why provider mutation success, rollback posture, visible
failure, and postcondition truth must remain distinct.
- [#1586: command execution token rejected despite command-enabled setup](https://github.com/rcourtman/Pulse/issues/1586)
demonstrates that current executor readiness and authorization must be
checked before an action is offered.
- [Docker Engine API: restart a container](https://docs.docker.com/reference/api/engine/version/v1.46/#tag/Container/operation/ContainerRestart)
defines restart as a narrow container-scoped provider operation and returns
transport/provider acceptance, not proof of application health.
- [Docker Engine API](https://docs.docker.com/reference/api/engine/) is
versioned and exposes separate inspect/event state, supporting a readback
boundary rather than treating the restart response as verification.
- [Kubernetes probes](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/)
likewise separate container lifecycle state from readiness and workload
health. Pulse keeps the same distinction even though this first capability is
Docker-specific.
The selected capability is intentionally smaller than update/recreate,
package-cache cleanup, or broad autonomous remediation. It directly answers a
public request while fitting the already-declared capability, approval,
delivery, receipt, and verification contracts.
## Runtime and API Result
- `internal/ai/attention_actions.go` owns the pure eligibility and offer
projection.
- `internal/api/attention_actions.go` owns bounded enrichment and
`POST /api/ai/patrol/attention/{id}/actions/restart/plan`.
- Planning re-evaluates lifecycle evidence, resource capability, live executor
readiness, and operator authority immediately before calling the canonical
action lifecycle.
- `ActionOrigin` additively carries `operationalRecordId` and sorted,
deduplicated `evidenceIds`.
- Memory and SQLite stores expose bounded latest-action lookup by operational
record. SQLite uses an indexed JSON-origin query; a 200-record batch is one
store read.
- List enrichment happens after pagination. Detail enriches only the selected
item. Summary performs no action enrichment.
- The browser client can request only the fixed zero-parameter plan. It cannot
supply origin, target, evidence IDs, handler, actor, or authority.
- Existing action plan/decision/execute/detail routes and
`ActionReviewDialog` remain the only approval and execution lifecycle.
## Proof
Backend contract and integration:
```text
go test ./internal/actionlifecycle ./internal/actionplanner \
./internal/unifiedresources ./internal/ai ./internal/api -count=1
```
Focused proofs cover every fail-closed eligibility reason, origin/evidence
binding, slash-containing record IDs, plan replay, approval, exactly-once
execution, confirmed and contradicted verification, action-without-resolution,
fresh detector recovery, timeout after send, correlated late receipt, and
SQLite restart reconciliation without resend.
Frontend:
```text
npm run type-check
npm run test -- \
src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx \
src/features/actions/__tests__/ActionReviewDialog.test.tsx
npx eslint \
src/api/patrolAttention.ts \
src/features/patrol/PatrolAttentionWorkbench.tsx \
src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx
```
Browser:
```text
PLAYWRIGHT_BASE_URL=http://127.0.0.1:5173 \
npx playwright test \
tests/91-operational-trust-attention-workbench.spec.ts \
--project=chromium
PLAYWRIGHT_BASE_URL=http://127.0.0.1:5173 \
npx playwright test \
tests/91-operational-trust-attention-workbench.spec.ts \
--project=mobile-chrome
```
Both five-journey matrices pass. The governed-action journeys prove policy
disclosure, approval, exactly one execute request, confirmed and contradicted
verification copy, still-open lifecycle truth, focus restoration after the
detail node is refreshed, screen-reader names, 390-pixel layout, no document
overflow, and reduced motion.
## Remaining Specification Work
This record accepts Phase 5 only. It does not close the overall Operational
Trust goal or primary candidate. Phase 6 still owns the complete compatibility,
migration, concurrency, failure, retention, load, telemetry, documentation,
upgrade, and final cross-repository governance matrix required by all fourteen
completion criteria.

View file

@ -8727,6 +8727,11 @@
"path": "docs/release-control/v6/internal/records/operational-trust-alert-state-boundary-2026-07-18.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/operational-trust-governed-docker-restart-2026-07-19.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/operational-trust-lifecycle-evidence-notification-linkage-2026-07-19.md",

View file

@ -4625,13 +4625,38 @@ operator approve or reject an agent action while preserving the agent token's
separate reporting and command authority; invalid credentials must never reach
the action handler or acquire an operator principal.
### Multi-destination reporting authority
The Unified Agent may fan one collected host, Docker/Podman, and Kubernetes
snapshot out to one primary Pulse destination and zero or more report-only
observers. Exactly one destination is authoritative. Only that primary may
provide remote configuration, commands, enrollment, update selection, or
canonical agent identity. Observer response bodies cannot enter any control
path. Delivery queues, authentication failures, and persisted host-report
buffers are destination-scoped; failure of one destination cannot block or
replay reports to another.
Observer configuration is explicit, versioned, and file-backed. It contains no
raw token values and resolves each token from a separate private absolute-path
file. Proxmox registration is also destination-scoped: the primary retains its
legacy token name for upgrade continuity, observers use distinct token names
and state markers, and every setup path must obtain a successful registration
state response before any create, delete, or rotation command is executed.
The adjacent recovery handlers under `internal/api/` do not widen this agent
lifecycle boundary. Protection posture is a read-only `monitoring:read`
projection over recovery points and provider collection evidence. It does not
register agents, issue or rotate credentials, interpret observer responses,
or grant backup, restore, command, or remote-configuration authority.
The adjacent Patrol attention handlers and shared router registration also do
not widen agent authority. They read the canonical alert lifecycle under
`monitoring:read`; they do not accept agent reports, mint credentials, deliver
commands, or reinterpret an observer response as configuration.
The adjacent Patrol attention read handlers and shared router registration do
not widen agent authority. Reads consume the canonical alert lifecycle under
`monitoring:read`; they do not accept agent reports, mint credentials, or
reinterpret an observer response as configuration. Phase 5 may plan one
evidence-gated Docker restart only after current plan/approve/execute authority,
declared capability, and executor readiness checks. That attention handler
binds an internal origin and enters the existing action lifecycle; it does not
deliver a command itself or add an agent wire shape. Actual dispatch retains
the canonical action executor, exact agent/resource binding, command-enabled
token policy, durable attempt/receipt, timeout, and restart-reconciliation
boundaries.

View file

@ -6618,3 +6618,20 @@ and pre/post inventory equality. Legacy `pulse-qual-${run_id}` names remain
accepted for external manifest compatibility, but governed remediation
qualification uses neutral identities so a model is scored on infrastructure
evidence rather than recognizing that it is inside a benchmark.
### Operational Trust action eligibility
`internal/ai/attention_actions.go` is the canonical pure projection for the
first attention-owned action offer. It maps only a
`docker-container-health` lifecycle item on an app-container to the existing
declared Docker `restart` capability. Freshness, completeness, confidence,
permissions, canonical subject binding, unambiguous correlation, live resource
capability, exact internal handler, approval floor, executor readiness, and
operator authority all fail closed through typed reasons. Frontend and API
callers must not reproduce those gates from finding text or loose metadata.
An origin-bound existing action may be projected for authorized historical
review. Verification presentation is derived only from the durable canonical
action state and `ActionResultV2`: confirmed, contradicted, inconclusive, and
not-attempted remain distinct. The projection has no authority to resolve the
operational record.

View file

@ -7821,3 +7821,26 @@ boundaries. Recovery points now expose provider scope and typed evidence, and
rollups expose their already-owned verification intent and last-verification
time. Supported legacy provider, subject, and display aliases remain readable;
they do not create a parallel posture authority.
### Operational Trust attention action transport
Attention list and detail payloads add typed action offers and verification
state without changing supported lifecycle fields. Offer enrichment occurs
only after bounded list pagination or for one selected detail. It performs one
registry build, one store acquisition, one authority evaluation, and one
bounded latest-action batch read for the visible page; summary performs none.
`POST /api/ai/patrol/attention/{id}/actions/restart/plan` is the only
Operational Trust mutation route introduced in Phase 5. The server parses
record IDs containing slashes, reloads the canonical projection, and
re-evaluates lifecycle evidence, subject identity, resource capability,
executor readiness, and current plan/approve/execute authority. The public
request supplies no target, parameters, evidence IDs, handler, actor, or
origin. Planning stamps the internal `operational_trust_attention` origin,
exact operational record ID, sorted evidence IDs, and a stable record-bound
request ID before using the existing action lifecycle. A prior origin-bound
plan is replayed rather than duplicated.
Decision, execution, receipt, reconciliation, result, and verification remain
on the existing `/api/actions` contract. Provider success does not mutate the
operational record; only fresh detector recovery evidence may resolve it.

View file

@ -2339,6 +2339,17 @@ detail, while legacy Patrol analytics belong in collapsed supporting context.
Unavailable and partial states must use explicit copy rather than success
styling.
The selected attention detail may compose the shared Actions review for an
eligible backend-authored offer. The detail owns only the expected
postcondition, explicit-review warning, verification summary, and one review
trigger. `ActionReviewDialog` remains the sole approve/reject/run and durable
outcome primitive. After a decision or execution refreshes and replaces the
detail subtree, dialog close must resolve and focus the current action trigger,
not a detached element reference. Browser proof covers desktop and
390-pixel mobile layouts, reduced motion, screen-reader names, exactly one run
request, and focus restoration for both confirmed and contradicted
verification.
## Current State
Assistant availability in the app shell is derived from the

View file

@ -968,6 +968,24 @@ observation remains stale or unknown and must not be presented as recovered.
The attached-facet browser proof is
`tests/integration/tests/92-operational-trust-availability-facet.spec.ts`.
Phase 5 adds one deliberately narrow mutation without changing that ownership
model. A selected canonical `docker-container-health` item may show the shared
AI-runtime offer for the resource's declared Docker `restart` capability.
`PatrolAttentionWorkbench` may plan or reopen that exact action, but all
approval, execution, policy evidence, delivery, audit, and verification
controls remain in the shared `ActionReviewDialog`. Patrol never reconstructs
eligibility from copy or metadata and never closes the issue because the
action command or running-state postcondition succeeded. It reports confirmed,
contradicted, or inconclusive verification beside the still-open lifecycle
record until fresh detector-owned recovery evidence resolves it.
The review trigger must restore focus to the current rendered control after
action refresh replaces the selected detail node. Desktop and mobile browser
proof, including failed verification and reduced motion, remains
`tests/integration/tests/91-operational-trust-attention-workbench.spec.ts`.
The durable decision and proof record is
`internal/records/operational-trust-governed-docker-restart-2026-07-19.md`.
## Current State
The active Patrol queue now uses compact severity-accented rows for

View file

@ -1932,3 +1932,12 @@ The authenticated app bootstrap performs one bounded Patrol attention summary
read and stores only low-cardinality counts. Queue list reads are paginated to
at most 200 items, posture joins batch at 200 subjects, and the summary path
does not scan recovery history or perform per-resource reads.
Phase 5 action enrichment preserves that bound. It runs only after attention
pagination (or for one selected detail), builds the resource registry once,
opens the action store once, evaluates actor authority once, and performs one
latest-action batch read for at most 200 operational record IDs. SQLite uses
the indexed `origin_json` operational-record expression; MemoryStore performs
one bounded pass. The summary path performs no registry, action-store,
executor-readiness, or action-origin work. Readiness checks remain local to
the already-bounded visible page and do not issue browser requests per row.

View file

@ -1426,3 +1426,21 @@ The apply route accepts that opaque token only and re-verifies authoritative
Stripe ownership and current state before mutation. Downgrade artifact cleanup
must stay within the tenant `reports/generated` directory and skip symlinks so
commercial retention cannot become an arbitrary-file deletion primitive.
Multi-destination Unified Agent configuration preserves authority separation.
The primary token is never reused for an observer, observer tokens are loaded
only from private regular non-symlink files, and observer URLs independently
enforce TLS, CA, fingerprint, and explicit plaintext policy. Observer payload
responses are report acknowledgements only and cannot authorize configuration,
commands, enrollment, or updates. Per-destination Proxmox tokens prevent one
Pulse instance from rotating credentials used by another.
Operational Trust action offers enforce current plan, approve, and execute
authority before a mutating affordance is returned. Planning repeats those
checks after reloading the selected canonical record and before entering the
shared action lifecycle; execution retains the existing fresh authorization,
plan-hash, policy, and delivery gates. The browser cannot claim a first-party
origin, target, evidence set, handler, actor, or parameters. The server binds
the exact operational record and policy-shaped evidence IDs internally.
Unauthorized, stale, partial, permission-limited, ambiguous, unsupported, or
executor-unready records return no offer and no cross-resource detail.

View file

@ -2180,6 +2180,16 @@ while storage detail drawers and filter controls must route summary series IDs,
source tones, and disk metrics through the shared storage helpers instead of
reconstructing them from local table state.
The adjacent Operational Trust attention action transport does not turn
protection posture into mutation authority. The selected item may display
storage/recovery-owned posture beside a separately declared Docker restart
offer, but recovery points, provider job evidence, backup age, verification
intent, and posture state cannot authorize that restart. The Phase 5 route
adds no backup, restore, retention, or recovery execution API and does not
change storage/recovery lifecycle ownership; it enters the existing governed
action lifecycle only after the action subsystem's own current evidence,
capability, executor-readiness, and operator-authority gates pass.
### Canonical protection posture
`ProtectionPosture` is the storage/recovery-owned subject read model over

View file

@ -1471,7 +1471,8 @@ AI-only summary payloads, or page-local heuristics.
`internal/unifiedresources/registry_test.go`.
26. Keep action-audit origin metadata broker-owned. `ActionAuditRecord`
carries an optional `Origin *ActionOrigin`
(`surface`/`findingId`/`investigationId`/`proposalId`), persisted in
(`surface`/`findingId`/`investigationId`/`proposalId`/
`operationalRecordId`/`evidenceIds`), persisted in
the `action_audits.origin_json` column (added by
`migrateActionAuditsSchema`) and round-tripped through
`scanActionAuditRecord`. Origin identifies which internal surface
@ -1480,8 +1481,9 @@ AI-only summary payloads, or page-local heuristics.
in-process planning callers through the action lifecycle service's
plan options; the public `POST /api/actions/plan` body must never be
able to claim a first-party origin. `NormalizeActionOrigin` trims
fields and collapses an all-empty origin to nil so absent metadata
never persists as an empty object. Origin fields are Pulse-produced
fields, sorts and deduplicates evidence IDs, and collapses an all-empty
origin to nil so absent metadata never persists as an empty object.
Origin fields are Pulse-produced
identifiers, not operator text, and stay outside the redaction set.
Downstream reconciliation of origin-tagged records rides the shared
lifecycle service's org-scoped `OnActionTransition` hook (wired via
@ -1489,8 +1491,17 @@ AI-only summary payloads, or page-local heuristics.
only after the corresponding store write succeeds, so a subscriber
keyed by org ID never observes a state this store could still lose
or apply it to the wrong tenant.
Regression coverage: `TestSQLiteStoreActionAuditOriginRoundTrip` in
`internal/unifiedresources/store_test.go`.
Operational Trust attention continuity uses the optional singular and
bounded batch operational-record readers. Memory and SQLite return the
latest action per record with the same clone semantics. SQLite guards the
JSON expression, uses the
`idx_action_audits_origin_operational_record_updated` index, and resolves
at most 200 requested records in one query so Patrol pagination cannot
become a per-row store pattern.
Regression coverage:
`TestSQLiteStoreActionAuditOriginRoundTrip`,
`TestSQLiteActionAuditOriginOperationalRecordReader`, and the matching
memory/batch reader tests in `internal/unifiedresources/store_test.go`.
27. Keep API-added TrueNAS systems keyed by the configured connection,
never by snapshot-reported identity. `systemSourceID` in
`internal/truenas/provider.go` scopes the system source ID (and every

View file

@ -8,6 +8,7 @@ import {
getPatrolAttention,
getPatrolAttentionDetail,
getPatrolAttentionSummary,
planPatrolAttentionAction,
} from '@/api/patrolAttention';
import { apiFetchJSON } from '@/utils/apiClient';
@ -33,4 +34,16 @@ describe('Patrol attention API', () => {
await getPatrolAttentionDetail('record/one');
expect(fetchMock).toHaveBeenLastCalledWith('/api/ai/patrol/attention/record%2Fone');
});
it('plans the fixed attention capability without accepting public action authority', async () => {
await planPatrolAttentionAction('record/one', 'restart');
expect(fetchMock).toHaveBeenLastCalledWith(
'/api/ai/patrol/attention/record%2Fone/actions/restart/plan',
{
method: 'POST',
body: '{}',
},
);
});
});

View file

@ -9,27 +9,28 @@ import type {
OperationalState,
} from '@/types/operationalTrust';
import type { ProtectionPosture } from '@/types/recovery';
import type { ActionAuditPlan } from '@/types/actionAudit';
export type AttentionFilter =
| 'active'
| 'open'
| 'acknowledged'
| 'suppressed'
| 'stale_unknown'
| 'resolved'
| 'all';
'active' | 'open' | 'acknowledged' | 'suppressed' | 'stale_unknown' | 'resolved' | 'all';
export type AttentionVerificationState =
| 'not_available'
| 'pending'
| 'succeeded'
| 'failed'
| 'unknown';
'not_available' | 'pending' | 'succeeded' | 'failed' | 'unknown';
export interface AttentionActionOffer {
actionId?: string;
targetResourceId: string;
capability: string;
kind: string;
label: string;
mode: 'plan' | 'dry-run' | 'execute';
risk: string;
approval: 'not-required' | 'required' | 'granted' | 'denied';
eligibility: 'eligible' | 'ineligible' | 'unknown';
reasons: string[];
evidenceIds: string[];
expectedPostcondition: string;
verificationPolicy: string;
requiresApproval: boolean;
}
@ -43,6 +44,7 @@ export interface AttentionItem {
subjectResourceId: string;
subjectResourceName: string;
subjectResourceType?: string;
kind: string;
title: string;
plainLanguageSummary: string;
severity: OperationalSeverity;
@ -111,3 +113,16 @@ export async function getPatrolAttentionDetail(itemId: string): Promise<Attentio
`/api/ai/patrol/attention/${encodeURIComponent(itemId)}`,
);
}
export async function planPatrolAttentionAction(
itemId: string,
capability: string,
): Promise<ActionAuditPlan> {
return apiFetchJSON<ActionAuditPlan>(
`/api/ai/patrol/attention/${encodeURIComponent(itemId)}/actions/${encodeURIComponent(capability)}/plan`,
{
method: 'POST',
body: '{}',
},
);
}

View file

@ -15,16 +15,21 @@ import ChevronRightIcon from 'lucide-solid/icons/chevron-right';
import ClockIcon from 'lucide-solid/icons/clock';
import ExternalLinkIcon from 'lucide-solid/icons/external-link';
import RefreshIcon from 'lucide-solid/icons/refresh-cw';
import RotateCwIcon from 'lucide-solid/icons/rotate-cw';
import SparklesIcon from 'lucide-solid/icons/sparkles';
import XIcon from 'lucide-solid/icons/x';
import type {
AttentionFilter,
AttentionActionOffer,
AttentionItem,
AttentionItemDetail,
} from '@/api/patrolAttention';
import { planPatrolAttentionAction } from '@/api/patrolAttention';
import { ResourceActionsAPI } from '@/api/resourceActions';
import { Button, ButtonLink } from '@/components/shared/Button';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
import { MetadataBadge, type MetadataBadgeTone } from '@/components/shared/MetadataBadge';
import { ActionReviewDialog } from '@/features/actions/ActionReviewDialog';
import { aiChatStore } from '@/stores/aiChat';
import { patrolAttentionStore } from '@/stores/patrolAttention';
import {
@ -34,6 +39,7 @@ import {
parsePatrolAttentionItemId,
} from '@/routing/resourceLinks';
import type { EvidenceEnvelope } from '@/types/operationalTrust';
import type { ActionDetailResponse } from '@/types/actionAudit';
import { formatRelativeTime } from '@/utils/format';
const PRIMARY_EVIDENCE_LIMIT = 3;
@ -50,8 +56,12 @@ const FILTERS: Array<{ id: AttentionFilter; label: string }> = [
export function PatrolAttentionWorkbench() {
const location = useLocation();
const [selectedItemId, setSelectedItemId] = createSignal('');
const [actionDetail, setActionDetail] = createSignal<ActionDetailResponse | null>(null);
const [actionBusy, setActionBusy] = createSignal(false);
const [actionError, setActionError] = createSignal('');
const itemButtons = new Map<string, HTMLButtonElement>();
let detailPanel: HTMLDivElement | undefined;
let actionTrigger: HTMLButtonElement | undefined;
const selectedDetail = () => patrolAttentionStore.selectedDetail();
const summary = () => patrolAttentionStore.summary();
@ -101,6 +111,44 @@ export function PatrolAttentionWorkbench() {
closeDetail();
void patrolAttentionStore.load(filter);
};
const reviewAction = async (
item: AttentionItem,
offer: AttentionActionOffer,
trigger: HTMLButtonElement,
) => {
if (actionBusy()) return;
actionTrigger = trigger;
setActionBusy(true);
setActionError('');
try {
const actionId =
offer.actionId || (await planPatrolAttentionAction(item.id, offer.capability)).actionId;
setActionDetail(await ResourceActionsAPI.getAction(actionId));
} catch (cause) {
setActionError(
cause instanceof Error ? cause.message : 'The governed action could not be opened.',
);
} finally {
setActionBusy(false);
}
};
const closeActionReview = () => {
setActionDetail(null);
queueMicrotask(() => {
const currentTrigger = detailPanel?.querySelector<HTMLButtonElement>(
'[data-patrol-action-trigger]',
);
(currentTrigger ?? actionTrigger)?.focus();
});
};
const actionChanged = async (next: ActionDetailResponse) => {
setActionDetail(next);
const selected = selectedItemId();
await Promise.all([
selected ? patrolAttentionStore.select(selected) : Promise.resolve(),
patrolAttentionStore.load(patrolAttentionStore.filter()),
]);
};
onMount(() => {
void patrolAttentionStore.load('active');
@ -171,7 +219,11 @@ export function PatrolAttentionWorkbench() {
</Button>
</div>
<div class="mt-4 flex gap-2 overflow-x-auto pb-1" role="group" aria-label="Attention filter">
<div
class="mt-4 flex gap-2 overflow-x-auto pb-1"
role="group"
aria-label="Attention filter"
>
<For each={FILTERS}>
{(option) => {
const selected = () => patrolAttentionStore.filter() === option.id;
@ -216,10 +268,18 @@ export function PatrolAttentionWorkbench() {
detail={selectedDetail()}
loading={patrolAttentionStore.detailLoading()}
onClose={closeDetail}
actionBusy={actionBusy()}
actionError={actionError()}
onReviewAction={reviewAction}
/>
</div>
</Show>
</div>
<ActionReviewDialog
detail={actionDetail()}
onClose={closeActionReview}
onChanged={actionChanged}
/>
</section>
);
}
@ -262,10 +322,7 @@ function AttentionList(props: {
</div>
}
>
<Show
when={patrolAttentionStore.items().length > 0}
fallback={<AttentionEmptyState />}
>
<Show when={patrolAttentionStore.items().length > 0} fallback={<AttentionEmptyState />}>
<ul class="divide-y divide-border" aria-label="Patrol attention items">
<For each={patrolAttentionStore.items()}>
{(item) => (
@ -303,7 +360,10 @@ function AttentionList(props: {
<span>{formatRelativeTime(item.firstObservedAt, { compact: true })}</span>
</div>
</div>
<ChevronRightIcon class="mt-1 h-4 w-4 shrink-0 text-muted" aria-hidden="true" />
<ChevronRightIcon
class="mt-1 h-4 w-4 shrink-0 text-muted"
aria-hidden="true"
/>
</div>
</button>
</li>
@ -332,9 +392,7 @@ function AttentionEmptyState() {
fallback={
<>
<ClockIcon class="h-8 w-8 text-muted" aria-hidden="true" />
<h3 class="mt-3 text-sm font-semibold text-base-content">
No items in this view
</h3>
<h3 class="mt-3 text-sm font-semibold text-base-content">No items in this view</h3>
<p class="mt-1 max-w-md text-xs leading-5 text-muted">
{summary()?.coverageState === 'partial'
? 'The lifecycle queue is empty, but protection context is incomplete. Pulse is not treating that gap as proof of health.'
@ -344,9 +402,7 @@ function AttentionEmptyState() {
}
>
<CheckCircleIcon class="h-9 w-9 text-emerald-500" aria-hidden="true" />
<h3 class="mt-3 text-sm font-semibold text-base-content">
Nothing needs your attention
</h3>
<h3 class="mt-3 text-sm font-semibold text-base-content">Nothing needs your attention</h3>
<p class="mt-1 max-w-md text-xs leading-5 text-muted">
The current operational lifecycle evaluation has no active items.
<Show when={summary()?.evaluatedAt}>
@ -362,13 +418,19 @@ function AttentionDetail(props: {
detail: AttentionItemDetail | null;
loading: boolean;
onClose: () => void;
actionBusy: boolean;
actionError: string;
onReviewAction: (
item: AttentionItem,
offer: AttentionActionOffer,
trigger: HTMLButtonElement,
) => void;
}) {
const detail = () => props.detail;
const item = () => detail()?.item;
const orderedEvidence = createMemo(() =>
[...(detail()?.evidence ?? [])].sort(
(left, right) =>
new Date(right.observedAt).getTime() - new Date(left.observedAt).getTime(),
(left, right) => new Date(right.observedAt).getTime() - new Date(left.observedAt).getTime(),
),
);
const primaryEvidence = createMemo(() => orderedEvidence().slice(0, PRIMARY_EVIDENCE_LIMIT));
@ -407,9 +469,7 @@ function AttentionDetail(props: {
detailLines: [
current.plainLanguageSummary,
current.impact ? `Impact: ${current.impact}` : undefined,
current.recommendedNextStep
? `Next step: ${current.recommendedNextStep}`
: undefined,
current.recommendedNextStep ? `Next step: ${current.recommendedNextStep}` : undefined,
].filter((line): line is string => Boolean(line)),
evidence: evidence.slice(0, 5),
actionLabel: `Explain ${current.title}`,
@ -492,9 +552,7 @@ function AttentionDetail(props: {
<p class="text-sm font-medium text-base-content">
{loaded().item.subjectResourceName}
</p>
<p class="mt-1 break-all text-xs text-muted">
{loaded().item.subjectResourceId}
</p>
<p class="mt-1 break-all text-xs text-muted">{loaded().item.subjectResourceId}</p>
<Show when={loaded().item.relatedResources.length > 0}>
<p class="mt-2 text-xs text-muted">
{loaded().item.relatedResources.length} related{' '}
@ -510,14 +568,56 @@ function AttentionDetail(props: {
</Show>
<Show when={loaded().item.recommendedNextStep}>
{(nextStep) => (
<p class="mt-2 text-sm font-medium leading-5 text-base-content">
{nextStep()}
</p>
<p class="mt-2 text-sm font-medium leading-5 text-base-content">{nextStep()}</p>
)}
</Show>
</DetailSection>
</Show>
<Show when={loaded().item.availableActions[0]}>
{(offer) => (
<DetailSection title="Safe action">
<div class="rounded-md border border-blue-200 bg-blue-50/70 p-3 dark:border-blue-900 dark:bg-blue-950/30">
<div class="flex items-start gap-2">
<RotateCwIcon
class="mt-0.5 h-4 w-4 shrink-0 text-blue-600 dark:text-blue-300"
aria-hidden="true"
/>
<div class="min-w-0">
<p class="text-sm font-semibold text-base-content">{offer().label}</p>
<p class="mt-1 text-xs leading-5 text-muted">
{offer().expectedPostcondition}{' '}
{attentionActionGuidance(loaded().item, offer())}
</p>
<ActionVerificationMessage state={loaded().item.verificationState} />
<Show when={props.actionError}>
<p
role="alert"
class="mt-2 text-xs leading-5 text-red-700 dark:text-red-300"
>
{props.actionError}
</p>
</Show>
<Button
variant="primary"
size="sm"
class="mt-3 gap-1.5"
data-patrol-action-trigger
isLoading={props.actionBusy}
onClick={(event) =>
props.onReviewAction(loaded().item, offer(), event.currentTarget)
}
>
<RotateCwIcon class="h-4 w-4" aria-hidden="true" />
{offer().actionId ? 'Review action' : 'Review and approve'}
</Button>
</div>
</div>
</div>
</DetailSection>
)}
</Show>
<DetailSection title="Evidence">
<Show
when={orderedEvidence().length > 0}
@ -639,6 +739,44 @@ function AttentionDetail(props: {
);
}
function ActionVerificationMessage(props: { state: AttentionItem['verificationState'] }) {
const message = () => {
switch (props.state) {
case 'pending':
return 'The action is awaiting a decision, execution, or verification.';
case 'succeeded':
return 'The restart postcondition was confirmed. This issue stays open until fresh health evidence shows the container is healthy.';
case 'failed':
return 'The restart did not satisfy its postcondition. The issue remains open.';
case 'unknown':
return 'Pulse could not conclusively verify the restart. The issue remains open.';
default:
return '';
}
};
return (
<Show when={message()}>
{(value) => <p class="mt-2 text-xs font-medium leading-5 text-base-content">{value()}</p>}
</Show>
);
}
function attentionActionGuidance(item: AttentionItem, offer: AttentionActionOffer): string {
if (!offer.actionId) {
return 'This requires an explicit review and approval before Pulse sends anything.';
}
switch (item.verificationState) {
case 'pending':
return 'Open the existing review to continue the governed action.';
case 'succeeded':
case 'failed':
case 'unknown':
return 'Pulse recorded the action result below. Open the review for the full audit.';
default:
return 'Open the existing review to inspect the recorded decision.';
}
}
function DetailSection(props: { title: string; children: import('solid-js').JSX.Element }) {
return (
<section>
@ -705,12 +843,7 @@ function SeverityMarker(props: { item: AttentionItem }) {
function StateBadge(props: { item: AttentionItem }) {
return (
<MetadataBadge
tone={stateTone(props.item)}
size="xs"
shape="rounded"
appearance="outline"
>
<MetadataBadge tone={stateTone(props.item)} size="xs" shape="rounded" appearance="outline">
{formatLabel(props.item.state)}
</MetadataBadge>
);
@ -718,16 +851,14 @@ function StateBadge(props: { item: AttentionItem }) {
function EvidenceLabel(props: { item: AttentionItem; badge?: boolean }) {
const label = () =>
props.item.evidenceFreshness === 'fresh' &&
props.item.evidenceCompleteness === 'complete'
props.item.evidenceFreshness === 'fresh' && props.item.evidenceCompleteness === 'complete'
? 'Evidence current'
: `${formatLabel(props.item.evidenceFreshness)} / ${formatLabel(props.item.evidenceCompleteness)}`;
if (props.badge) {
return (
<MetadataBadge
tone={
props.item.evidenceFreshness === 'fresh' &&
props.item.evidenceCompleteness === 'complete'
props.item.evidenceFreshness === 'fresh' && props.item.evidenceCompleteness === 'complete'
? 'success'
: 'warning'
}

View file

@ -12,6 +12,8 @@ const apiMocks = vi.hoisted(() => ({
getList: vi.fn(),
getDetail: vi.fn(),
getSummary: vi.fn(),
planAction: vi.fn(),
getAction: vi.fn(),
}));
vi.mock('@/api/patrolAttention', async (importOriginal) => {
@ -21,6 +23,38 @@ vi.mock('@/api/patrolAttention', async (importOriginal) => {
getPatrolAttention: (...args: unknown[]) => apiMocks.getList(...args),
getPatrolAttentionDetail: (...args: unknown[]) => apiMocks.getDetail(...args),
getPatrolAttentionSummary: (...args: unknown[]) => apiMocks.getSummary(...args),
planPatrolAttentionAction: (...args: unknown[]) => apiMocks.planAction(...args),
};
});
vi.mock('@/api/resourceActions', () => ({
ResourceActionsAPI: {
getAction: (...args: unknown[]) => apiMocks.getAction(...args),
},
}));
vi.mock('@/features/actions/ActionReviewDialog', async () => {
const { Show } = await import('solid-js');
return {
ActionReviewDialog: (props: {
detail: { audit?: { id?: string } } | null;
onClose: () => void;
onChanged: (next: { audit?: { id?: string } }) => Promise<void> | void;
}) => (
<Show when={props.detail}>
{(value) => (
<div role="dialog" aria-label="Governed action review">
<span>{value().audit?.id}</span>
<button type="button" onClick={() => void props.onChanged(value())}>
Complete action
</button>
<button type="button" onClick={props.onClose}>
Close action review
</button>
</div>
)}
</Show>
),
};
});
@ -48,6 +82,7 @@ const item = (overrides: Partial<AttentionItem> = {}): AttentionItem => ({
subjectResourceId: 'pve:vm:101',
subjectResourceName: 'Database VM',
subjectResourceType: 'vm',
kind: 'disk',
title: 'Disk pressure on Database VM',
plainLanguageSummary: 'The database disk is nearly full.',
severity: 'critical',
@ -135,6 +170,8 @@ describe('PatrolAttentionWorkbench', () => {
apiMocks.getList.mockReset();
apiMocks.getDetail.mockReset();
apiMocks.getSummary.mockReset();
apiMocks.planAction.mockReset();
apiMocks.getAction.mockReset();
});
afterEach(() => {
@ -154,8 +191,9 @@ describe('PatrolAttentionWorkbench', () => {
apiMocks.getList.mockResolvedValue(listResponse([], calm));
renderWorkbench();
expect(await screen.findByRole('heading', { name: 'Nothing needs your attention' }))
.toBeInTheDocument();
expect(
await screen.findByRole('heading', { name: 'Nothing needs your attention' }),
).toBeInTheDocument();
expect(screen.getByText(/current operational lifecycle evaluation/i)).toBeInTheDocument();
expect(screen.queryByText(/trust score/i)).not.toBeInTheDocument();
expect(screen.queryByText(/auto-resolved/i)).not.toBeInTheDocument();
@ -178,8 +216,9 @@ describe('PatrolAttentionWorkbench', () => {
name: 'Disk pressure on Database VM',
});
expect(within(detailRegion).getByText(/Impact: Writes may fail\./)).toBeInTheDocument();
expect(within(detailRegion).getByText(/latest backup has not been verified/i))
.toBeInTheDocument();
expect(
within(detailRegion).getByText(/latest backup has not been verified/i),
).toBeInTheDocument();
expect(within(detailRegion).getByText('Proxmox VE')).toBeInTheDocument();
expect(within(detailRegion).getByText('Observing to Open')).toBeInTheDocument();
expect(window.location.search).toBe('?attention=record-1');
@ -224,7 +263,77 @@ describe('PatrolAttentionWorkbench', () => {
expect(screen.getByRole('heading', { name: 'No items in this view' })).toBeInTheDocument();
});
expect(screen.getByText(/protection context is incomplete/i)).toBeInTheDocument();
expect(screen.queryByRole('heading', { name: 'Nothing needs your attention' }))
.not.toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'Nothing needs your attention' }),
).not.toBeInTheDocument();
});
it('opens the canonical governed action review from an eligible attention item', async () => {
const actionOffer = {
targetResourceId: 'docker:host-1/container-1',
capability: 'restart',
kind: 'container_restart',
label: 'Restart this container',
mode: 'plan' as const,
risk: 'low' as const,
approval: 'required' as const,
eligibility: 'eligible' as const,
reasons: ['fresh_confirmed_unhealthy_container'],
evidenceIds: ['evidence-1'],
expectedPostcondition: 'The same container is observed running after the restart.',
verificationPolicy: 'Pulse requires a fresh container readback.',
requiresApproval: true,
};
const active = item({
subjectResourceId: 'docker:host-1/container-1',
subjectResourceName: 'API container',
subjectResourceType: 'app-container',
kind: 'docker-container-health',
title: 'API container is unhealthy',
availableActions: [actionOffer],
});
const completed = item({
...active,
availableActions: [{ ...actionOffer, actionId: 'act-attention-restart' }],
verificationState: 'succeeded',
});
apiMocks.getList.mockResolvedValue(
listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })),
);
apiMocks.getDetail
.mockResolvedValueOnce(detail(active))
.mockResolvedValueOnce(detail(completed));
apiMocks.planAction.mockResolvedValue({ actionId: 'act-attention-restart' });
apiMocks.getAction.mockResolvedValue({ audit: { id: 'act-attention-restart' } });
renderWorkbench();
fireEvent.click(
await screen.findByRole('button', {
name: 'Open API container is unhealthy',
}),
);
const trigger = await screen.findByRole('button', { name: 'Review and approve' });
expect(
screen.getByText(/explicit review and approval before Pulse sends anything/i),
).toBeInTheDocument();
fireEvent.click(trigger);
await waitFor(() => {
expect(apiMocks.planAction).toHaveBeenCalledWith('record-1', 'restart');
expect(apiMocks.getAction).toHaveBeenCalledWith('act-attention-restart');
});
expect(
await screen.findByRole('dialog', { name: 'Governed action review' }),
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Complete action' }));
const currentTrigger = await screen.findByRole('button', { name: 'Review action' });
expect(currentTrigger).not.toBe(trigger);
expect(screen.getByText(/recorded the action result below/i)).toBeInTheDocument();
expect(
screen.queryByText(/explicit review and approval before Pulse sends anything/i),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Close action review' }));
await waitFor(() => expect(currentTrigger).toHaveFocus());
});
});

View file

@ -55,10 +55,20 @@ const (
)
type AttentionActionOffer struct {
Capability string `json:"capability"`
Label string `json:"label"`
Risk string `json:"risk"`
RequiresApproval bool `json:"requiresApproval"`
ActionID string `json:"actionId,omitempty"`
TargetResourceID string `json:"targetResourceId"`
Capability string `json:"capability"`
Kind string `json:"kind"`
Label string `json:"label"`
Mode string `json:"mode"`
Risk string `json:"risk"`
Approval string `json:"approval"`
Eligibility string `json:"eligibility"`
Reasons []string `json:"reasons"`
EvidenceIDs []string `json:"evidenceIds"`
ExpectedPostcondition string `json:"expectedPostcondition"`
VerificationPolicy string `json:"verificationPolicy"`
RequiresApproval bool `json:"requiresApproval"`
}
type AttentionResource struct {
@ -71,6 +81,7 @@ type AttentionItem struct {
SubjectResourceID string `json:"subjectResourceId"`
SubjectResourceName string `json:"subjectResourceName"`
SubjectResourceType string `json:"subjectResourceType,omitempty"`
Kind string `json:"kind"`
Title string `json:"title"`
PlainLanguageSummary string `json:"plainLanguageSummary"`
Severity operationaltrust.OperationalSeverity `json:"severity"`
@ -218,6 +229,7 @@ func projectAttentionAlert(
SubjectResourceID: record.SubjectResourceID,
SubjectResourceName: resourceName,
SubjectResourceType: attentionResourceType(alert),
Kind: strings.TrimSpace(alert.Type),
Title: title,
PlainLanguageSummary: summary,
Severity: record.Severity,

View file

@ -0,0 +1,237 @@
package ai
import (
"sort"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
const (
AttentionDockerHealthKind = "docker-container-health"
AttentionDockerRestartCapability = "restart"
AttentionDockerLifecycleHandler = "docker.container.lifecycle"
)
type AttentionActionEligibilityReason string
const (
AttentionActionEligible AttentionActionEligibilityReason = "eligible"
AttentionActionUnsupportedCondition AttentionActionEligibilityReason = "unsupported_condition"
AttentionActionLifecycleInactive AttentionActionEligibilityReason = "lifecycle_inactive"
AttentionActionEvidenceMissing AttentionActionEligibilityReason = "evidence_missing"
AttentionActionEvidenceStale AttentionActionEligibilityReason = "evidence_stale"
AttentionActionEvidenceIncomplete AttentionActionEligibilityReason = "evidence_incomplete"
AttentionActionEvidenceUnconfirmed AttentionActionEligibilityReason = "evidence_unconfirmed"
AttentionActionEvidencePermissionLimited AttentionActionEligibilityReason = "evidence_permission_limited"
AttentionActionEvidenceSubjectMismatch AttentionActionEligibilityReason = "evidence_subject_mismatch"
AttentionActionResourceUnavailable AttentionActionEligibilityReason = "resource_unavailable"
AttentionActionCapabilityUnavailable AttentionActionEligibilityReason = "capability_unavailable"
AttentionActionExecutionUnavailable AttentionActionEligibilityReason = "execution_unavailable"
AttentionActionOperatorUnauthorized AttentionActionEligibilityReason = "operator_unauthorized"
)
type AttentionActionCandidate struct {
Resource *unified.Resource
Readiness unified.ResourceActionReadiness
Authorized bool
Action *unified.ActionAuditRecord
}
// ProjectAttentionAction evaluates the one deliberately narrow first action
// against canonical lifecycle evidence and live resource capability state.
// API and UI callers must not reconstruct these gates from labels or metadata.
func ProjectAttentionAction(
detail *AttentionItemDetail,
candidate AttentionActionCandidate,
now time.Time,
) (AttentionActionOffer, AttentionActionEligibilityReason) {
if detail == nil {
return AttentionActionOffer{}, AttentionActionUnsupportedCondition
}
item := &detail.Item
if existing := candidate.Action; AttentionActionMatchesItem(*item, existing) {
if !candidate.Authorized {
return AttentionActionOffer{}, AttentionActionOperatorUnauthorized
}
return attentionDockerRestartOffer(*item, detail.Evidence, existing), AttentionActionEligible
}
if item.Kind != AttentionDockerHealthKind ||
item.SubjectResourceType != string(unified.ResourceTypeAppContainer) {
return AttentionActionOffer{}, AttentionActionUnsupportedCondition
}
if item.State != operationaltrust.OperationalOpen &&
item.State != operationaltrust.OperationalAcknowledged {
return AttentionActionOffer{}, AttentionActionLifecycleInactive
}
if len(detail.Evidence) == 0 {
return AttentionActionOffer{}, AttentionActionEvidenceMissing
}
for _, evidence := range detail.Evidence {
if evidence.FreshnessAt(now) != operationaltrust.EvidenceFresh {
return AttentionActionOffer{}, AttentionActionEvidenceStale
}
if evidence.Completeness != operationaltrust.EvidenceComplete {
return AttentionActionOffer{}, AttentionActionEvidenceIncomplete
}
if evidence.Confidence != operationaltrust.EvidenceConfirmed {
return AttentionActionOffer{}, AttentionActionEvidenceUnconfirmed
}
if evidence.Permissions != operationaltrust.EvidencePermissionsSufficient {
return AttentionActionOffer{}, AttentionActionEvidencePermissionLimited
}
if unified.CanonicalResourceID(evidence.Subject.ResourceID) !=
unified.CanonicalResourceID(item.SubjectResourceID) {
return AttentionActionOffer{}, AttentionActionEvidenceSubjectMismatch
}
if evidence.Correlation != nil && evidence.Correlation.CandidateCount != 1 {
return AttentionActionOffer{}, AttentionActionEvidenceSubjectMismatch
}
}
if candidate.Resource == nil ||
unified.CanonicalResourceID(candidate.Resource.ID) !=
unified.CanonicalResourceID(item.SubjectResourceID) {
return AttentionActionOffer{}, AttentionActionResourceUnavailable
}
found := false
for _, capability := range candidate.Resource.Capabilities {
if strings.TrimSpace(capability.Name) == AttentionDockerRestartCapability &&
strings.TrimSpace(capability.InternalHandler) == AttentionDockerLifecycleHandler &&
capability.MinimumApprovalLevel == unified.ApprovalAdmin {
found = true
break
}
}
if !found {
return AttentionActionOffer{}, AttentionActionCapabilityUnavailable
}
if !candidate.Readiness.Available ||
strings.TrimSpace(candidate.Readiness.Name) != AttentionDockerRestartCapability {
return AttentionActionOffer{}, AttentionActionExecutionUnavailable
}
if !candidate.Authorized {
return AttentionActionOffer{}, AttentionActionOperatorUnauthorized
}
return attentionDockerRestartOffer(*item, detail.Evidence, nil), AttentionActionEligible
}
func attentionDockerRestartOffer(
item AttentionItem,
evidence []operationaltrust.EvidenceEnvelope,
action *unified.ActionAuditRecord,
) AttentionActionOffer {
evidenceIDs := make([]string, 0, len(evidence))
if action != nil && action.Origin != nil {
evidenceIDs = append(evidenceIDs, action.Origin.EvidenceIDs...)
} else {
for _, envelope := range evidence {
evidenceIDs = append(evidenceIDs, envelope.ID)
}
}
evidenceIDs = uniqueSortedAttentionEvidenceIDs(evidenceIDs)
offer := AttentionActionOffer{
TargetResourceID: item.SubjectResourceID,
Capability: AttentionDockerRestartCapability,
Kind: "container_restart",
Label: "Restart this container",
Mode: "plan",
Risk: "low",
Approval: "required",
Eligibility: "eligible",
Reasons: []string{"fresh_confirmed_unhealthy_container", "declared_live_capability"},
EvidenceIDs: evidenceIDs,
ExpectedPostcondition: "The same container is observed running after the restart.",
VerificationPolicy: "Pulse requires a fresh container readback and records whether it is agent-attested or independently observed.",
RequiresApproval: true,
}
if action == nil {
return offer
}
offer.ActionID = action.ID
offer.Mode = "execute"
offer.Approval = attentionActionApproval(*action)
return offer
}
// AttentionActionMatchesItem validates that a durable action belongs to the
// exact canonical record, resource, and bounded capability being projected.
func AttentionActionMatchesItem(
item AttentionItem,
action *unified.ActionAuditRecord,
) bool {
if action == nil || action.Origin == nil {
return false
}
return strings.TrimSpace(action.Origin.OperationalRecordID) ==
strings.TrimSpace(item.OperationalRecordID) &&
unified.CanonicalResourceID(action.Request.ResourceID) ==
unified.CanonicalResourceID(item.SubjectResourceID) &&
strings.TrimSpace(action.Request.CapabilityName) ==
AttentionDockerRestartCapability
}
func uniqueSortedAttentionEvidenceIDs(values []string) []string {
seen := make(map[string]struct{}, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, found := seen[value]; found {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
sort.Strings(result)
return result
}
func attentionActionApproval(action unified.ActionAuditRecord) string {
switch action.State {
case unified.ActionStateApproved,
unified.ActionStateExecuting,
unified.ActionStateCompleted,
unified.ActionStateFailed:
return "granted"
case unified.ActionStateRejected:
return "denied"
default:
return "required"
}
}
func AttentionActionVerificationState(
action *unified.ActionAuditRecord,
) AttentionVerificationState {
if action == nil {
return AttentionVerificationNotAvailable
}
switch action.State {
case unified.ActionStatePlanned,
unified.ActionStatePending,
unified.ActionStateApproved,
unified.ActionStateExecuting:
return AttentionVerificationPending
case unified.ActionStateRejected, unified.ActionStateExpired:
return AttentionVerificationNotAvailable
}
if action.Result != nil && action.Result.ActionResultV2 != nil {
switch action.Result.ActionResultV2.Verification.Status {
case unified.ActionVerificationConfirmed:
return AttentionVerificationSucceeded
case unified.ActionVerificationContradicted:
return AttentionVerificationFailed
case unified.ActionVerificationInconclusive,
unified.ActionVerificationNotAttempted:
return AttentionVerificationUnknown
}
}
if action.State == unified.ActionStateFailed {
return AttentionVerificationFailed
}
return AttentionVerificationUnknown
}

View file

@ -0,0 +1,224 @@
package ai
import (
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/operationaltrust"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
func TestProjectAttentionActionRequiresFreshConfirmedCanonicalEvidence(t *testing.T) {
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
detail := attentionDockerHealthDetail(now)
resource := attentionDockerResource(detail.Item.SubjectResourceID)
eligible := AttentionActionCandidate{
Resource: &resource,
Readiness: unified.ResourceActionReadiness{Name: "restart", Available: true},
Authorized: true,
}
offer, reason := ProjectAttentionAction(&detail, eligible, now)
if reason != AttentionActionEligible {
t.Fatalf("reason = %q, want eligible", reason)
}
if offer.Capability != "restart" ||
offer.TargetResourceID != detail.Item.SubjectResourceID ||
offer.Approval != "required" ||
offer.Eligibility != "eligible" ||
len(offer.EvidenceIDs) != 1 {
t.Fatalf("offer = %+v", offer)
}
tests := []struct {
name string
mutate func(*AttentionItemDetail, *AttentionActionCandidate)
want AttentionActionEligibilityReason
}{
{
name: "stale evidence",
mutate: func(detail *AttentionItemDetail, _ *AttentionActionCandidate) {
expired := now.Add(-time.Second)
detail.Evidence[0].ValidUntil = &expired
},
want: AttentionActionEvidenceStale,
},
{
name: "partial evidence",
mutate: func(detail *AttentionItemDetail, _ *AttentionActionCandidate) {
detail.Evidence[0].Completeness = operationaltrust.EvidencePartial
},
want: AttentionActionEvidenceIncomplete,
},
{
name: "permission limited",
mutate: func(detail *AttentionItemDetail, _ *AttentionActionCandidate) {
detail.Evidence[0].Permissions = operationaltrust.EvidencePermissionsPartial
},
want: AttentionActionEvidencePermissionLimited,
},
{
name: "ambiguous subject",
mutate: func(detail *AttentionItemDetail, _ *AttentionActionCandidate) {
detail.Evidence[0].Correlation = &operationaltrust.IdentityCorrelation{
Rule: "hostname",
MatchedFields: map[string]string{
"hostname": "api",
},
CandidateCount: 2,
}
},
want: AttentionActionEvidenceSubjectMismatch,
},
{
name: "capability missing",
mutate: func(_ *AttentionItemDetail, candidate *AttentionActionCandidate) {
candidate.Resource.Capabilities = nil
},
want: AttentionActionCapabilityUnavailable,
},
{
name: "executor refused",
mutate: func(_ *AttentionItemDetail, candidate *AttentionActionCandidate) {
candidate.Readiness.Available = false
candidate.Readiness.ReasonCode = "agent_disconnected"
},
want: AttentionActionExecutionUnavailable,
},
{
name: "executor readiness missing",
mutate: func(_ *AttentionItemDetail, candidate *AttentionActionCandidate) {
candidate.Readiness = unified.ResourceActionReadiness{}
},
want: AttentionActionExecutionUnavailable,
},
{
name: "operator unauthorized",
mutate: func(_ *AttentionItemDetail, candidate *AttentionActionCandidate) {
candidate.Authorized = false
},
want: AttentionActionOperatorUnauthorized,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
currentDetail := attentionDockerHealthDetail(now)
currentResource := attentionDockerResource(currentDetail.Item.SubjectResourceID)
currentCandidate := AttentionActionCandidate{
Resource: &currentResource,
Readiness: unified.ResourceActionReadiness{Name: "restart", Available: true},
Authorized: true,
}
test.mutate(&currentDetail, &currentCandidate)
if offer, got := ProjectAttentionAction(&currentDetail, currentCandidate, now); got != test.want || offer.Capability != "" {
t.Fatalf("offer=%+v reason=%q, want empty/%q", offer, got, test.want)
}
})
}
}
func TestProjectAttentionActionUsesOnlyTheExistingRecordBoundAction(t *testing.T) {
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC)
detail := attentionDockerHealthDetail(now)
detail.Item.State = operationaltrust.OperationalResolved
action := unified.ActionAuditRecord{
ID: "action-1",
State: unified.ActionStateCompleted,
Request: unified.ActionRequest{
ResourceID: detail.Item.SubjectResourceID,
CapabilityName: AttentionDockerRestartCapability,
},
Origin: &unified.ActionOrigin{
OperationalRecordID: detail.Item.OperationalRecordID,
EvidenceIDs: []string{"planned-evidence", "planned-evidence"},
},
}
offer, reason := ProjectAttentionAction(
&detail,
AttentionActionCandidate{Action: &action, Authorized: true},
now,
)
if reason != AttentionActionEligible ||
offer.ActionID != action.ID ||
len(offer.EvidenceIDs) != 1 ||
offer.EvidenceIDs[0] != "planned-evidence" {
t.Fatalf("offer=%+v reason=%q", offer, reason)
}
action.Request.ResourceID = "docker:other/container"
detail.Item.State = operationaltrust.OperationalOpen
resource := attentionDockerResource(detail.Item.SubjectResourceID)
offer, reason = ProjectAttentionAction(
&detail,
AttentionActionCandidate{
Resource: &resource,
Readiness: unified.ResourceActionReadiness{Name: "restart", Available: true},
Authorized: true,
Action: &action,
},
now,
)
if reason != AttentionActionEligible || offer.ActionID != "" {
t.Fatalf("mismatched action offer=%+v reason=%q", offer, reason)
}
}
func TestAttentionActionVerificationStatePreservesExecutionAndPostconditionTruth(t *testing.T) {
record := unified.ActionAuditRecord{State: unified.ActionStateExecuting}
if got := AttentionActionVerificationState(&record); got != AttentionVerificationPending {
t.Fatalf("executing = %q", got)
}
record.State = unified.ActionStateCompleted
record.Result = &unified.ExecutionResult{ActionResultV2: &unified.ActionResultV2{
Version: unified.ActionResultV2Version,
Verification: unified.ActionVerificationTruth{
Status: unified.ActionVerificationConfirmed,
EvidenceClass: unified.ActionEvidenceAgentAttested,
},
}}
if got := AttentionActionVerificationState(&record); got != AttentionVerificationSucceeded {
t.Fatalf("confirmed = %q", got)
}
record.Result.ActionResultV2.Verification.Status = unified.ActionVerificationContradicted
if got := AttentionActionVerificationState(&record); got != AttentionVerificationFailed {
t.Fatalf("contradicted = %q", got)
}
record.Result.ActionResultV2.Verification.Status = unified.ActionVerificationInconclusive
if got := AttentionActionVerificationState(&record); got != AttentionVerificationUnknown {
t.Fatalf("inconclusive = %q", got)
}
}
func attentionDockerHealthDetail(now time.Time) AttentionItemDetail {
resourceID := "docker:host-1/container-1"
evidence := attentionTestEvidence(resourceID, now)
return AttentionItemDetail{
Item: AttentionItem{
ID: "operational-docker-health",
OperationalRecordID: "operational-docker-health",
SubjectResourceID: resourceID,
SubjectResourceName: "api",
SubjectResourceType: string(unified.ResourceTypeAppContainer),
Kind: AttentionDockerHealthKind,
State: operationaltrust.OperationalOpen,
},
OperationalRecord: operationaltrust.OperationalRecord{
ID: "operational-docker-health",
SubjectResourceID: resourceID,
},
Evidence: []operationaltrust.EvidenceEnvelope{evidence},
}
}
func attentionDockerResource(resourceID string) unified.Resource {
return unified.Resource{
ID: resourceID,
Type: unified.ResourceTypeAppContainer,
Capabilities: []unified.ResourceCapability{{
Name: AttentionDockerRestartCapability,
Type: unified.CapabilityTypeCommon,
MinimumApprovalLevel: unified.ApprovalAdmin,
InternalHandler: AttentionDockerLifecycleHandler,
}},
}
}

View file

@ -0,0 +1,240 @@
package api
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/actionlifecycle"
"github.com/rcourtman/pulse-go-rewrite/internal/ai"
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rcourtman/pulse-go-rewrite/internal/utils"
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
const operationalTrustActionOriginSurface = "operational_trust_attention"
func isAttentionActionPlanPath(path string) bool {
_, capability, ok := parseAttentionActionPlanPath(path)
return ok && capability != ""
}
func parseAttentionActionPlanPath(path string) (string, string, bool) {
path = strings.Trim(path, "/")
actionBoundary := strings.LastIndex(path, "/actions/")
if actionBoundary <= 0 {
return "", "", false
}
itemPart := path[:actionBoundary]
actionPart := path[actionBoundary+len("/actions/"):]
actionSegments := strings.Split(actionPart, "/")
if len(actionSegments) != 2 || actionSegments[1] != "plan" {
return "", "", false
}
itemID, err := url.PathUnescape(itemPart)
if err != nil {
return "", "", false
}
capability, err := url.PathUnescape(actionSegments[0])
if err != nil {
return "", "", false
}
itemID = strings.TrimSpace(itemID)
capability = strings.TrimSpace(capability)
return itemID, capability, itemID != "" && capability != ""
}
func (h *AttentionHandlers) projectAttentionActions(
r *http.Request,
details []ai.AttentionItemDetail,
) {
if h == nil || h.resources == nil || len(details) == 0 {
return
}
orgID := GetOrgID(r.Context())
registry, registryErr := h.resources.buildRegistry(orgID)
store, storeErr := h.resources.getStore(orgID)
actor, actorErr := actionActorForRequest(h.resources.cfg, r, orgID)
authorized := actorErr == nil &&
h.actionAuthority.authorizeActor(r.Context(), orgID, actor, auth.ActionPlan) == nil &&
h.actionAuthority.authorizeActor(r.Context(), orgID, actor, auth.ActionApprove) == nil &&
h.actionAuthority.authorizeActor(r.Context(), orgID, actor, auth.ActionExecute) == nil
actions := make(map[string]unified.ActionAuditRecord, len(details))
if storeErr == nil {
if reader, ok := store.(unified.OperationalActionAuditOriginBatchReader); ok {
recordIDs := make([]string, 0, len(details))
for index := range details {
recordIDs = append(recordIDs, details[index].Item.OperationalRecordID)
}
if records, err := reader.GetLatestActionAuditsByOperationalRecords(
operationalTrustActionOriginSurface,
recordIDs,
); err == nil {
actions = records
}
}
}
now := time.Now().UTC()
for index := range details {
detail := &details[index]
candidate := ai.AttentionActionCandidate{Authorized: authorized}
if record, found := actions[detail.Item.OperationalRecordID]; found &&
ai.AttentionActionMatchesItem(detail.Item, &record) {
candidate.Action = &record
detail.Item.VerificationState = ai.AttentionActionVerificationState(&record)
}
if registryErr == nil {
if resource, found := registry.Get(detail.Item.SubjectResourceID); found {
candidate.Resource = resource
candidate.Readiness = unified.ResourceActionReadiness{
Name: ai.AttentionDockerRestartCapability,
Available: false,
ReasonCode: "executor_readiness_unavailable",
Reason: "Current action readiness could not be verified.",
}
if checker, ok := h.resources.actionExecutor.(ActionAvailabilityChecker); ok {
candidate.Readiness = checker.CheckActionAvailable(
r.Context(),
unified.ActionRequest{
RequestID: attentionActionRequestID(detail.Item.OperationalRecordID),
ResourceID: detail.Item.SubjectResourceID,
CapabilityName: ai.AttentionDockerRestartCapability,
Params: map[string]any{},
Reason: attentionActionReason(detail.Item),
},
*resource,
)
}
}
}
offer, reason := ai.ProjectAttentionAction(detail, candidate, now)
if reason == ai.AttentionActionEligible {
detail.Item.AvailableActions = []ai.AttentionActionOffer{offer}
}
}
}
func (h *AttentionHandlers) handleAttentionActionPlan(
w http.ResponseWriter,
r *http.Request,
path string,
) {
if mock.IsMockEnabled() {
writeJSONError(w, http.StatusForbidden, "attention_action_mock_mode", "Cannot plan actions in mock mode")
return
}
itemID, capability, ok := parseAttentionActionPlanPath(path)
if !ok {
writeJSONError(w, http.StatusNotFound, "attention_action_not_found", "Attention action was not found")
return
}
if capability != ai.AttentionDockerRestartCapability {
writeJSONError(w, http.StatusConflict, "attention_action_ineligible", "This action is not eligible for the selected attention item")
return
}
if h == nil || h.resources == nil {
writeJSONError(w, http.StatusServiceUnavailable, "attention_actions_unavailable", "Governed actions are unavailable")
return
}
projection, err := h.project(r, true)
if err != nil {
writeAttentionUnavailable(w, err)
return
}
var detail *ai.AttentionItemDetail
for index := range projection.Details {
if projection.Details[index].Item.ID == itemID {
detail = &projection.Details[index]
break
}
}
if detail == nil {
writeJSONError(w, http.StatusNotFound, "attention_item_not_found", "Attention item was not found")
return
}
enriched := []ai.AttentionItemDetail{*detail}
h.projectAttentionActions(r, enriched)
detail = &enriched[0]
var offer *ai.AttentionActionOffer
for index := range detail.Item.AvailableActions {
if detail.Item.AvailableActions[index].Capability == capability {
offer = &detail.Item.AvailableActions[index]
break
}
}
if offer == nil {
writeJSONError(w, http.StatusConflict, "attention_action_ineligible", "Current evidence, capability state, or operator authority does not permit this action")
return
}
orgID := GetOrgID(r.Context())
actor, err := actionActorForRequest(h.resources.cfg, r, orgID)
if err != nil ||
h.actionAuthority.authorizeActor(r.Context(), orgID, actor, auth.ActionPlan) != nil ||
h.actionAuthority.authorizeActor(r.Context(), orgID, actor, auth.ActionApprove) != nil ||
h.actionAuthority.authorizeActor(r.Context(), orgID, actor, auth.ActionExecute) != nil {
writeJSONError(w, http.StatusForbidden, "attention_action_denied", "You do not have permission to review and run this action")
return
}
if offer.ActionID != "" {
record, found, getErr := h.resources.ActionLifecycle().Get(orgID, offer.ActionID)
if getErr != nil || !found {
writeJSONError(w, http.StatusServiceUnavailable, "attention_action_query_failed", "The governed action record is unavailable")
return
}
writeAttentionActionPlan(w, record.Plan)
return
}
plan, err := h.resources.ActionLifecycle().PlanWithOptions(
r.Context(),
orgID,
unified.ActionRequest{
RequestID: attentionActionRequestID(detail.Item.OperationalRecordID),
ResourceID: detail.Item.SubjectResourceID,
CapabilityName: capability,
Params: map[string]any{},
Reason: attentionActionReason(detail.Item),
},
actionlifecycle.PlanOptions{
Actor: actor,
Origin: &unified.ActionOrigin{
Surface: operationalTrustActionOriginSurface,
OperationalRecordID: detail.Item.OperationalRecordID,
EvidenceIDs: append([]string(nil), offer.EvidenceIDs...),
},
},
)
if err != nil {
writeActionPlanError(w, err)
return
}
writeAttentionActionPlan(w, plan)
}
func writeAttentionActionPlan(w http.ResponseWriter, plan unified.ActionPlan) {
if err := utils.WriteJSONResponse(w, plan); err != nil {
writeJSONError(w, http.StatusInternalServerError, "attention_action_encode_failed", "Failed to encode action plan")
}
}
func attentionActionRequestID(operationalRecordID string) string {
return "operational-trust:" + strings.TrimSpace(operationalRecordID) + ":" +
ai.AttentionDockerRestartCapability
}
func attentionActionReason(item ai.AttentionItem) string {
name := strings.TrimSpace(item.SubjectResourceName)
if name == "" {
name = strings.TrimSpace(item.SubjectResourceID)
}
return fmt.Sprintf(
"Restart %s after fresh confirmed evidence reported an unhealthy container.",
name,
)
}

View file

@ -0,0 +1,289 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"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/operationaltrust"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
type attentionActionTestExecutor struct {
result *unified.ExecutionResult
calls int
}
func (e *attentionActionTestExecutor) ExecuteAction(
context.Context,
unified.ActionAuditRecord,
) (*unified.ExecutionResult, error) {
e.calls++
return e.result, nil
}
func (*attentionActionTestExecutor) CheckActionAvailable(
_ context.Context,
request unified.ActionRequest,
_ unified.Resource,
) unified.ResourceActionReadiness {
return unified.ResourceActionReadiness{Name: request.CapabilityName, Available: true}
}
func TestAttentionActionPlanBindsCanonicalRecordEvidenceAndReplaysIdempotently(t *testing.T) {
now := time.Now().UTC()
alert := attentionHandlerAlert("docker-health-record", operationaltrust.OperationalOpen, now)
resourceID := "docker:host-1/container-1"
alert.Type = ai.AttentionDockerHealthKind
alert.ResourceID = resourceID
alert.ResourceName = "API container"
alert.Metadata = map[string]interface{}{"resourceType": string(unified.ResourceTypeAppContainer)}
alert.OperationalRecord.SubjectResourceID = resourceID
alert.Evidence[0].Subject.ResourceID = resourceID
resources := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
resources.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{{
ID: resourceID,
Type: unified.ResourceTypeAppContainer,
Name: "API container",
Status: unified.StatusWarning,
LastSeen: now,
UpdatedAt: now,
Capabilities: []unified.ResourceCapability{{
Name: ai.AttentionDockerRestartCapability,
Type: unified.CapabilityTypeCommon,
Description: "Restart this Docker container.",
MinimumApprovalLevel: unified.ApprovalAdmin,
AutoAuthorization: unified.AutoAuthorizeLowRisk,
InternalHandler: ai.AttentionDockerLifecycleHandler,
}},
}},
})
executor := &attentionActionTestExecutor{result: &unified.ExecutionResult{
Success: true,
Output: "Container restart completed.",
Verification: &unified.ActionVerificationResult{
Ran: true,
Success: true,
Note: "Fresh container readback observed the same container running.",
},
}}
resources.SetActionExecutor(executor)
authority := testActionAuthority()
resolved := false
handler := &AttentionHandlers{
readAlerts: func(context.Context) ([]alerts.Alert, []alerts.Alert, error) {
if resolved {
return nil, []alerts.Alert{alert}, nil
}
return []alerts.Alert{alert}, nil, nil
},
}
handler.SetActionDependencies(resources, authority)
detailRequest := actionHandlerTestRequest(
httptest.NewRequest(
http.MethodGet,
"/api/ai/patrol/attention/docker-health-record",
nil,
),
"operator@example.com",
)
detailResponse := httptest.NewRecorder()
handler.HandleAttention(detailResponse, detailRequest)
if detailResponse.Code != http.StatusOK {
t.Fatalf("detail status=%d body=%s", detailResponse.Code, detailResponse.Body.String())
}
var detail ai.AttentionItemDetail
if err := json.Unmarshal(detailResponse.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode detail: %v", err)
}
if len(detail.Item.AvailableActions) != 1 ||
detail.Item.AvailableActions[0].Capability != ai.AttentionDockerRestartCapability ||
detail.Item.AvailableActions[0].ActionID != "" {
t.Fatalf("initial offers = %+v", detail.Item.AvailableActions)
}
var first unified.ActionPlan
for attempt := 0; attempt < 2; attempt++ {
request := actionHandlerTestRequest(
httptest.NewRequest(
http.MethodPost,
"/api/ai/patrol/attention/docker-health-record/actions/restart/plan",
nil,
),
"operator@example.com",
)
response := httptest.NewRecorder()
handler.HandleAttention(response, request)
if response.Code != http.StatusOK {
t.Fatalf("attempt %d status=%d body=%s", attempt, response.Code, response.Body.String())
}
var plan unified.ActionPlan
if err := json.Unmarshal(response.Body.Bytes(), &plan); err != nil {
t.Fatalf("decode plan: %v", err)
}
if attempt == 0 {
first = plan
} else if plan.ActionID != first.ActionID || plan.PlanHash != first.PlanHash {
t.Fatalf("replay plan=%+v first=%+v", plan, first)
}
}
if !first.RequiresApproval {
t.Fatalf("plan = %+v, want explicit approval", first)
}
store, err := resources.getStore("default")
if err != nil {
t.Fatalf("get store: %v", err)
}
reader, ok := store.(unified.OperationalActionAuditOriginReader)
if !ok {
t.Fatalf("%T lacks operational action lookup", store)
}
audit, found, err := reader.GetLatestActionAuditByOperationalRecord(
operationalTrustActionOriginSurface,
"docker-health-record",
)
if err != nil || !found {
t.Fatalf("origin audit found=%t err=%v", found, err)
}
if audit.ID != first.ActionID ||
audit.Origin == nil ||
audit.Origin.OperationalRecordID != "docker-health-record" ||
len(audit.Origin.EvidenceIDs) != 1 ||
audit.Origin.EvidenceIDs[0] != alert.Evidence[0].ID {
t.Fatalf("audit origin = %+v", audit.Origin)
}
decisionRequest := actionHandlerTestRequest(
httptest.NewRequest(
http.MethodPost,
"/api/actions/"+first.ActionID+"/decision",
bytes.NewBufferString(`{"outcome":"approved","planHash":"`+first.PlanHash+`"}`),
),
"operator@example.com",
)
decisionRequest.SetPathValue("id", first.ActionID)
decisionResponse := httptest.NewRecorder()
resources.HandleDecideAction(decisionResponse, decisionRequest)
if decisionResponse.Code != http.StatusOK {
t.Fatalf("decision status=%d body=%s", decisionResponse.Code, decisionResponse.Body.String())
}
for attempt := 0; attempt < 2; attempt++ {
executeRequest := actionHandlerTestRequest(
httptest.NewRequest(
http.MethodPost,
"/api/actions/"+first.ActionID+"/execute",
bytes.NewBufferString(`{"planHash":"`+first.PlanHash+`"}`),
),
"operator@example.com",
)
executeRequest.SetPathValue("id", first.ActionID)
executeResponse := httptest.NewRecorder()
resources.HandleExecuteAction(executeResponse, executeRequest)
if executeResponse.Code != http.StatusOK {
t.Fatalf("execute attempt %d status=%d body=%s", attempt, executeResponse.Code, executeResponse.Body.String())
}
}
if executor.calls != 1 {
t.Fatalf("executor calls=%d, want exactly one", executor.calls)
}
detailResponse = httptest.NewRecorder()
handler.HandleAttention(detailResponse, detailRequest)
if detailResponse.Code != http.StatusOK {
t.Fatalf("reloaded detail status=%d body=%s", detailResponse.Code, detailResponse.Body.String())
}
if err := json.Unmarshal(detailResponse.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode reloaded detail: %v", err)
}
if len(detail.Item.AvailableActions) != 1 ||
detail.Item.AvailableActions[0].ActionID != first.ActionID ||
detail.Item.VerificationState != ai.AttentionVerificationSucceeded ||
detail.Item.State != operationaltrust.OperationalOpen {
t.Fatalf("reloaded item = %+v", detail.Item)
}
// A successful command/readback never closes the operational record by
// itself. Only a fresh detector-owned recovery observation moves the item
// to resolved.
recoveredAt := time.Now().UTC().Add(time.Second)
alert.OperationalRecord.State = operationaltrust.OperationalResolved
alert.OperationalRecord.StateChangedAt = recoveredAt
alert.OperationalRecord.LastObservedAt = recoveredAt
alert.OperationalRecord.ResolvedAt = &recoveredAt
alert.Evidence[0].ObservedAt = recoveredAt
alert.Evidence[0].IngestedAt = recoveredAt
validUntil := recoveredAt.Add(time.Hour)
alert.Evidence[0].ValidUntil = &validUntil
resolved = true
detailResponse = httptest.NewRecorder()
handler.HandleAttention(detailResponse, detailRequest)
if detailResponse.Code != http.StatusOK {
t.Fatalf("resolved detail status=%d body=%s", detailResponse.Code, detailResponse.Body.String())
}
if err := json.Unmarshal(detailResponse.Body.Bytes(), &detail); err != nil {
t.Fatalf("decode resolved detail: %v", err)
}
if detail.Item.State != operationaltrust.OperationalResolved ||
detail.Item.VerificationState != ai.AttentionVerificationSucceeded {
t.Fatalf("resolved item = %+v", detail.Item)
}
}
func TestAttentionActionOfferFailsClosedWithoutFreshEvidenceOrExecutorReadiness(t *testing.T) {
now := time.Now().UTC()
detail := ai.AttentionItemDetail{
Item: ai.AttentionItem{
ID: "record",
OperationalRecordID: "record",
SubjectResourceID: "docker:host/container",
SubjectResourceType: string(unified.ResourceTypeAppContainer),
Kind: ai.AttentionDockerHealthKind,
State: operationaltrust.OperationalOpen,
},
Evidence: []operationaltrust.EvidenceEnvelope{{
ID: "evidence",
Subject: operationaltrust.EvidenceSubject{ResourceID: "docker:host/container"},
ObservedAt: now.Add(-time.Hour),
IngestedAt: now.Add(-time.Hour),
Completeness: operationaltrust.EvidenceComplete,
Confidence: operationaltrust.EvidenceConfirmed,
Permissions: operationaltrust.EvidencePermissionsSufficient,
}},
}
resource := unified.Resource{
ID: detail.Item.SubjectResourceID,
Type: unified.ResourceTypeAppContainer,
Capabilities: []unified.ResourceCapability{{
Name: ai.AttentionDockerRestartCapability,
MinimumApprovalLevel: unified.ApprovalAdmin,
InternalHandler: ai.AttentionDockerLifecycleHandler,
}},
}
if offer, reason := ai.ProjectAttentionAction(
&detail,
ai.AttentionActionCandidate{
Resource: &resource,
Readiness: unified.ResourceActionReadiness{
Name: ai.AttentionDockerRestartCapability,
Available: true,
},
Authorized: true,
},
now,
); reason != ai.AttentionActionEvidenceStale || offer.Capability != "" {
t.Fatalf("stale offer=%+v reason=%q", offer, reason)
}
}

View file

@ -31,6 +31,8 @@ type attentionAlertSnapshot func(context.Context) ([]alerts.Alert, []alerts.Aler
type AttentionHandlers struct {
readAlerts attentionAlertSnapshot
recoveryManager *recoverymanager.Manager
resources *ResourceHandlers
actionAuthority actionAuthority
}
func NewAttentionHandlers(
@ -53,6 +55,17 @@ func NewAttentionHandlers(
}
}
func (h *AttentionHandlers) SetActionDependencies(
resources *ResourceHandlers,
authority actionAuthority,
) {
if h == nil {
return
}
h.resources = resources
h.actionAuthority = authority
}
type attentionListResponse struct {
Data []ai.AttentionItem `json:"data"`
Summary ai.AttentionSummary `json:"summary"`
@ -65,12 +78,15 @@ type attentionListResponse struct {
}
func (h *AttentionHandlers) HandleAttention(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/ai/patrol/attention")
if r.Method == http.MethodPost && isAttentionActionPlanPath(path) {
h.handleAttentionActionPlan(w, r, path)
return
}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/ai/patrol/attention")
switch {
case path == "" || path == "/":
h.handleAttentionList(w, r)
@ -88,7 +104,7 @@ func (h *AttentionHandlers) handleAttentionList(w http.ResponseWriter, r *http.R
if !ok {
return
}
projection, err := h.project(r.Context(), true)
projection, err := h.project(r, true)
if err != nil {
writeAttentionUnavailable(w, err)
return
@ -115,6 +131,7 @@ func (h *AttentionHandlers) handleAttentionList(w http.ResponseWriter, r *http.R
)
return
}
h.projectAttentionActions(r, paged)
response := attentionListResponse{
Data: make([]ai.AttentionItem, 0, len(paged)),
@ -135,7 +152,7 @@ func (h *AttentionHandlers) handleAttentionList(w http.ResponseWriter, r *http.R
}
func (h *AttentionHandlers) handleAttentionSummary(w http.ResponseWriter, r *http.Request) {
projection, err := h.project(r.Context(), false)
projection, err := h.project(r, false)
if err != nil {
writeAttentionUnavailable(w, err)
return
@ -161,14 +178,16 @@ func (h *AttentionHandlers) handleAttentionDetail(
)
return
}
projection, err := h.project(r.Context(), true)
projection, err := h.project(r, true)
if err != nil {
writeAttentionUnavailable(w, err)
return
}
for _, detail := range projection.Details {
if detail.Item.ID == itemID {
if err := utils.WriteJSONResponse(w, detail); err != nil {
details := []ai.AttentionItemDetail{detail}
h.projectAttentionActions(r, details)
if err := utils.WriteJSONResponse(w, details[0]); err != nil {
log.Error().Err(err).Msg("Failed to serialize Patrol attention detail")
}
return
@ -184,12 +203,13 @@ func (h *AttentionHandlers) handleAttentionDetail(
}
func (h *AttentionHandlers) project(
ctx context.Context,
r *http.Request,
includeProtectionPosture bool,
) (ai.AttentionProjection, error) {
if h == nil || h.readAlerts == nil {
return ai.AttentionProjection{}, fmt.Errorf("attention lifecycle source is not configured")
}
ctx := r.Context()
active, history, err := h.readAlerts(ctx)
if err != nil {
return ai.AttentionProjection{}, err

View file

@ -175,6 +175,17 @@ func TestAttentionHandlersRejectInvalidOrUnboundedQueries(t *testing.T) {
}
}
func TestParseAttentionActionPlanPathPreservesOperationalIDsContainingSlashes(t *testing.T) {
itemID, capability, ok := parseAttentionActionPlanPath(
"/agent:node-1/disk:mnt-disk2::metric-threshold:disk/actions/restart/plan",
)
if !ok ||
itemID != "agent:node-1/disk:mnt-disk2::metric-threshold:disk" ||
capability != "restart" {
t.Fatalf("item=%q capability=%q ok=%t", itemID, capability, ok)
}
}
func attentionHandlerAlert(
id string,
state operationaltrust.OperationalState,

View file

@ -65,6 +65,52 @@ type basicActionContractAuthorizer struct {
wantUser string
}
func TestContract_OperationalTrustAttentionActionOfferJSONIsAdditiveAndTyped(t *testing.T) {
payload, err := json.Marshal(ai.AttentionActionOffer{
ActionID: "action-1",
TargetResourceID: "docker:host/container",
Capability: ai.AttentionDockerRestartCapability,
Kind: "container_restart",
Label: "Restart this container",
Mode: "execute",
Risk: "low",
Approval: "granted",
Eligibility: "eligible",
Reasons: []string{"fresh_confirmed_unhealthy_container"},
EvidenceIDs: []string{"evidence-1"},
ExpectedPostcondition: "The same container is observed running after the restart.",
VerificationPolicy: "Pulse requires a fresh container readback.",
RequiresApproval: true,
})
if err != nil {
t.Fatalf("marshal attention action offer: %v", err)
}
var contract map[string]any
if err := json.Unmarshal(payload, &contract); err != nil {
t.Fatalf("decode attention action offer: %v", err)
}
for _, field := range []string{
"actionId",
"targetResourceId",
"capability",
"kind",
"label",
"mode",
"risk",
"approval",
"eligibility",
"reasons",
"evidenceIds",
"expectedPostcondition",
"verificationPolicy",
"requiresApproval",
} {
if _, found := contract[field]; !found {
t.Fatalf("attention action offer missing %q: %s", field, payload)
}
}
}
func (a basicActionContractAuthorizer) Authorize(ctx context.Context, action, resource string) (bool, error) {
return authpkg.GetUser(ctx) == a.wantUser && action == authpkg.ActionApprove && resource == authpkg.ResourceActions, nil
}

View file

@ -494,6 +494,7 @@ func (r *Router) setupRoutes() {
actionOrgChecker := NewAuthorizationChecker(NewMultiTenantOrganizationLoader(r.multiTenant))
actionAuth := actionAuthority{authorizer: r.authorizer, orgChecker: actionOrgChecker}
r.resourceHandlers.SetActionAuthorizers(actionAuth, actionAuth)
r.attentionHandlers.SetActionDependencies(r.resourceHandlers, actionAuth)
r.maintenanceSentinel = r.buildMaintenanceVerificationSentinel()
r.maintenanceVerificationHandlers = NewMaintenanceVerificationHandlers(r.resourceHandlers, r.maintenanceSentinel)
if r.maintenanceSentinel != nil {

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
)
@ -484,10 +485,12 @@ type ActionAuditRecord struct {
// Origin is broker-owned metadata: it is set by in-process planning callers
// only and is never accepted from the public plan request body.
type ActionOrigin struct {
Surface string `json:"surface"`
FindingID string `json:"findingId,omitempty"`
InvestigationID string `json:"investigationId,omitempty"`
ProposalID string `json:"proposalId,omitempty"`
Surface string `json:"surface"`
FindingID string `json:"findingId,omitempty"`
InvestigationID string `json:"investigationId,omitempty"`
ProposalID string `json:"proposalId,omitempty"`
OperationalRecordID string `json:"operationalRecordId,omitempty"`
EvidenceIDs []string `json:"evidenceIds,omitempty"`
}
// NormalizeActionOrigin trims origin fields and collapses an all-empty
@ -497,12 +500,20 @@ func NormalizeActionOrigin(origin *ActionOrigin) *ActionOrigin {
return nil
}
normalized := ActionOrigin{
Surface: strings.TrimSpace(origin.Surface),
FindingID: strings.TrimSpace(origin.FindingID),
InvestigationID: strings.TrimSpace(origin.InvestigationID),
ProposalID: strings.TrimSpace(origin.ProposalID),
Surface: strings.TrimSpace(origin.Surface),
FindingID: strings.TrimSpace(origin.FindingID),
InvestigationID: strings.TrimSpace(origin.InvestigationID),
ProposalID: strings.TrimSpace(origin.ProposalID),
OperationalRecordID: strings.TrimSpace(origin.OperationalRecordID),
EvidenceIDs: uniqueStrings(origin.EvidenceIDs),
}
if normalized == (ActionOrigin{}) {
sort.Strings(normalized.EvidenceIDs)
if normalized.Surface == "" &&
normalized.FindingID == "" &&
normalized.InvestigationID == "" &&
normalized.ProposalID == "" &&
normalized.OperationalRecordID == "" &&
len(normalized.EvidenceIDs) == 0 {
return nil
}
return &normalized

View file

@ -98,6 +98,20 @@ type ActionAuditOriginReader interface {
GetLatestActionAuditByOrigin(surface, investigationID string) (ActionAuditRecord, bool, error)
}
// OperationalActionAuditOriginReader is the bounded origin lookup used by the
// Operational Trust attention read model. The operational record identifier is
// stable across resource observations, action retries, and process restarts.
type OperationalActionAuditOriginReader interface {
GetLatestActionAuditByOperationalRecord(surface, operationalRecordID string) (ActionAuditRecord, bool, error)
}
// OperationalActionAuditOriginBatchReader resolves a bounded attention page
// in one indexed store read. Attention list rendering must not issue one
// action-audit query per item.
type OperationalActionAuditOriginBatchReader interface {
GetLatestActionAuditsByOperationalRecords(surface string, operationalRecordIDs []string) (map[string]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.
@ -689,6 +703,13 @@ func (s *SQLiteResourceStore) migrateActionAuditsSchema() error {
`); err != nil {
return fmt.Errorf("create action audit origin investigation index: %w", err)
}
if _, err := s.db.Exec(`
CREATE INDEX IF NOT EXISTS idx_action_audits_origin_operational_record_updated
ON action_audits(json_extract(origin_json, '$.surface'), json_extract(origin_json, '$.operationalRecordId'), updated_at DESC)
WHERE json_valid(origin_json)
`); 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_state_updated
ON action_audits(state, updated_at ASC, created_at ASC)
@ -2263,6 +2284,85 @@ func (s *SQLiteResourceStore) GetLatestActionAuditByOrigin(surface, investigatio
return record, true, nil
}
func (s *SQLiteResourceStore) GetLatestActionAuditByOperationalRecord(surface, operationalRecordID string) (ActionAuditRecord, bool, error) {
surface = strings.TrimSpace(surface)
operationalRecordID = strings.TrimSpace(operationalRecordID)
if surface == "" || operationalRecordID == "" {
return ActionAuditRecord{}, false, nil
}
row := s.db.QueryRow(`
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 json_valid(origin_json)
AND json_extract(origin_json, '$.surface') = ?
AND json_extract(origin_json, '$.operationalRecordId') = ?
ORDER BY updated_at DESC, created_at DESC
LIMIT 1
`, surface, operationalRecordID)
record, err := scanActionAuditRecord(row)
if errors.Is(err, sql.ErrNoRows) {
return ActionAuditRecord{}, false, nil
}
if err != nil {
return ActionAuditRecord{}, false, fmt.Errorf("query action audit by operational record: %w", err)
}
return record, true, nil
}
func (s *SQLiteResourceStore) GetLatestActionAuditsByOperationalRecords(
surface string,
operationalRecordIDs []string,
) (map[string]ActionAuditRecord, error) {
surface = strings.TrimSpace(surface)
ids := uniqueStrings(operationalRecordIDs)
if surface == "" || len(ids) == 0 {
return map[string]ActionAuditRecord{}, nil
}
if len(ids) > 200 {
return nil, fmt.Errorf("operational action audit batch exceeds 200 records")
}
placeholders := make([]string, len(ids))
args := make([]any, 0, len(ids)+1)
args = append(args, surface)
for index, id := range ids {
placeholders[index] = "?"
args = append(args, id)
}
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 json_valid(origin_json)
AND json_extract(origin_json, '$.surface') = ?
AND json_extract(origin_json, '$.operationalRecordId') IN (`+strings.Join(placeholders, ",")+`)
ORDER BY updated_at DESC, created_at DESC
`, args...)
if err != nil {
return nil, fmt.Errorf("query action audits by operational records: %w", err)
}
defer rows.Close()
result := make(map[string]ActionAuditRecord, len(ids))
for rows.Next() {
record, scanErr := scanActionAuditRecord(rows)
if scanErr != nil {
return nil, fmt.Errorf("scan action audit by operational record: %w", scanErr)
}
if record.Origin == nil {
continue
}
recordID := strings.TrimSpace(record.Origin.OperationalRecordID)
if recordID == "" {
continue
}
if _, found := result[recordID]; !found {
result[recordID] = record
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate action audits by operational records: %w", err)
}
return result, nil
}
func (s *SQLiteResourceStore) GetPendingActionAudits(limit int) ([]ActionAuditRecord, error) {
if limit <= 0 || limit > 500 {
limit = 100
@ -3501,6 +3601,74 @@ func (m *MemoryStore) GetLatestActionAuditByOrigin(surface, investigationID stri
return cloneActionAuditRecordForRead(latest), true, nil
}
func (m *MemoryStore) GetLatestActionAuditByOperationalRecord(surface, operationalRecordID string) (ActionAuditRecord, bool, error) {
m.mu.RLock()
defer m.mu.RUnlock()
surface = strings.TrimSpace(surface)
operationalRecordID = strings.TrimSpace(operationalRecordID)
if surface == "" || operationalRecordID == "" {
return ActionAuditRecord{}, false, nil
}
var latest ActionAuditRecord
found := false
for _, record := range m.actionAudits {
if record.Origin == nil ||
strings.TrimSpace(record.Origin.Surface) != surface ||
strings.TrimSpace(record.Origin.OperationalRecordID) != operationalRecordID {
continue
}
if !found ||
record.UpdatedAt.After(latest.UpdatedAt) ||
(record.UpdatedAt.Equal(latest.UpdatedAt) && record.CreatedAt.After(latest.CreatedAt)) {
latest = record
found = true
}
}
if !found {
return ActionAuditRecord{}, false, nil
}
return cloneActionAuditRecordForRead(latest), true, nil
}
func (m *MemoryStore) GetLatestActionAuditsByOperationalRecords(
surface string,
operationalRecordIDs []string,
) (map[string]ActionAuditRecord, error) {
surface = strings.TrimSpace(surface)
ids := uniqueStrings(operationalRecordIDs)
if surface == "" || len(ids) == 0 {
return map[string]ActionAuditRecord{}, nil
}
if len(ids) > 200 {
return nil, fmt.Errorf("operational action audit batch exceeds 200 records")
}
wanted := make(map[string]struct{}, len(ids))
for _, id := range ids {
wanted[id] = struct{}{}
}
m.mu.RLock()
defer m.mu.RUnlock()
result := make(map[string]ActionAuditRecord, len(ids))
for _, record := range m.actionAudits {
if record.Origin == nil ||
strings.TrimSpace(record.Origin.Surface) != surface {
continue
}
recordID := strings.TrimSpace(record.Origin.OperationalRecordID)
if _, found := wanted[recordID]; !found {
continue
}
latest, found := result[recordID]
if !found ||
record.UpdatedAt.After(latest.UpdatedAt) ||
(record.UpdatedAt.Equal(latest.UpdatedAt) &&
record.CreatedAt.After(latest.CreatedAt)) {
result[recordID] = cloneActionAuditRecordForRead(record)
}
}
return result, nil
}
func (m *MemoryStore) GetPendingActionAudits(limit int) ([]ActionAuditRecord, error) {
m.mu.RLock()
defer m.mu.RUnlock()

View file

@ -3134,19 +3134,35 @@ func TestActionAuditOriginReaderReturnsLatestTransition(t *testing.T) {
if !ok {
t.Fatalf("%T does not implement ActionAuditOriginReader", store)
}
operationalReader, ok := store.(OperationalActionAuditOriginReader)
if !ok {
t.Fatalf("%T does not implement OperationalActionAuditOriginReader", store)
}
operationalBatchReader, ok := store.(OperationalActionAuditOriginBatchReader)
if !ok {
t.Fatalf("%T does not implement OperationalActionAuditOriginBatchReader", store)
}
now := time.Now().UTC()
for _, record := range []ActionAuditRecord{
{
ID: "act-old", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute), State: ActionStatePending,
Request: ActionRequest{RequestID: "prop-old", ResourceID: "vm:42", CapabilityName: "restart", RequestedBy: "pulse_patrol"},
Plan: ActionPlan{ActionID: "act-old", RequestID: "prop-old", Allowed: true},
Origin: &ActionOrigin{Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1", ProposalID: "prop-old"},
Origin: &ActionOrigin{
Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1",
ProposalID: "prop-old", OperationalRecordID: "operational-1",
EvidenceIDs: []string{" evidence-b ", "evidence-a", "evidence-a"},
},
},
{
ID: "act-new", CreatedAt: now, UpdatedAt: now, State: ActionStateCompleted,
Request: ActionRequest{RequestID: "prop-new", ResourceID: "vm:42", CapabilityName: "restart", RequestedBy: "pulse_patrol"},
Plan: ActionPlan{ActionID: "act-new", RequestID: "prop-new", Allowed: true},
Origin: &ActionOrigin{Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1", ProposalID: "prop-new"},
Request: ActionRequest{RequestID: "prop-new", ResourceID: "vm:42", CapabilityName: "restart", RequestedBy: "pulse_patrol"},
Plan: ActionPlan{ActionID: "act-new", RequestID: "prop-new", Allowed: true},
Origin: &ActionOrigin{
Surface: "patrol", FindingID: "finding-1", InvestigationID: "inv-1",
ProposalID: "prop-new", OperationalRecordID: "operational-1",
EvidenceIDs: []string{"evidence-c"},
},
VerificationOutcome: VerificationOutcome{Status: VerificationVerified},
},
} {
@ -3161,6 +3177,20 @@ func TestActionAuditOriginReaderReturnsLatestTransition(t *testing.T) {
if got.ID != "act-new" || got.State != ActionStateCompleted {
t.Fatalf("latest audit = %#v, want act-new completed", got)
}
got, found, err = operationalReader.GetLatestActionAuditByOperationalRecord(
"patrol",
"operational-1",
)
if err != nil || !found || got.ID != "act-new" {
t.Fatalf("GetLatestActionAuditByOperationalRecord: got=%#v found=%v err=%v", got, found, err)
}
batch, err := operationalBatchReader.GetLatestActionAuditsByOperationalRecords(
"patrol",
[]string{"operational-1", "missing"},
)
if err != nil || len(batch) != 1 || batch["operational-1"].ID != "act-new" {
t.Fatalf("GetLatestActionAuditsByOperationalRecords: batch=%#v err=%v", batch, err)
}
})
}
}

View file

@ -105,8 +105,15 @@ test.beforeEach(async ({ page }) => {
expect(pageErrors, "The Patrol shell raised a browser error").toEqual([]);
expect(consoleErrors, "The Patrol shell logged a browser error").toEqual([]);
const shellText = (await page.locator("body").innerText()).trim();
expect(shellText, `The authenticated shell was blank at ${page.url()}`).not.toBe("");
await expect(page.getByRole("tab", { name: /Patrol/ })).toBeVisible();
expect(
shellText,
`The authenticated shell was blank at ${page.url()}`,
).not.toBe("");
await expect(
page
.getByRole("tab", { name: /Patrol/ })
.or(page.getByRole("button", { name: "Patrol", exact: true })),
).toBeVisible();
});
const now = new Date();
@ -529,13 +536,13 @@ async function mockAttention(
? [uncertainAttentionItem]
: filter === "open"
? [openAttentionItem]
: filter === "acknowledged"
? [acknowledgedAttentionItem]
: filter === "suppressed"
? [suppressedAttentionItem]
: filter === "resolved"
? [resolvedAttentionItem]
: [openAttentionItem, uncertainAttentionItem];
: filter === "acknowledged"
? [acknowledgedAttentionItem]
: filter === "suppressed"
? [suppressedAttentionItem]
: filter === "resolved"
? [resolvedAttentionItem]
: [openAttentionItem, uncertainAttentionItem];
await route.fulfill({
status: 200,
contentType: "application/json",
@ -578,7 +585,11 @@ test("makes active operational work primary and preserves the evidence boundary"
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("tab", { name: "Patrol: 2 active attention items" }),
page.getByRole("tab", { name: "Patrol: 2 active attention items" }).or(
page.getByRole("button", {
name: "Patrol: 2 active attention items",
}),
),
).toBeVisible();
const queue = page.getByRole("region", { name: "Needs attention" });
await expect(queue.getByLabel("2 active attention items")).toBeVisible();
@ -717,3 +728,491 @@ test("shows calm only with current coverage and never converts failure into heal
).toBeVisible();
await expect(page.getByText("Nothing needs your attention")).toHaveCount(0);
});
type GovernedActionVerification = "confirmed" | "contradicted";
const governedActionAttentionID = "docker-health-operational-record";
const governedActionID = "act-operational-trust-restart";
const governedResourceID = "docker:host-1/container-api";
const governedActionOffer = (actionId?: string) => ({
...(actionId ? { actionId } : {}),
targetResourceId: governedResourceID,
capability: "restart",
kind: "container_restart",
label: "Restart this container",
mode: actionId ? "execute" : "plan",
risk: "low",
approval: actionId ? "granted" : "required",
eligibility: "eligible",
reasons: ["fresh_confirmed_unhealthy_container", "declared_live_capability"],
evidenceIds: ["docker-health-evidence"],
expectedPostcondition:
"The same container is observed running after the restart.",
verificationPolicy:
"Pulse requires a fresh container readback and records whether it is agent-attested or independently observed.",
requiresApproval: true,
});
const governedActionAttentionItem = (
actionState: "unplanned" | "pending_approval" | "approved" | "completed",
verification: GovernedActionVerification,
) => ({
id: governedActionAttentionID,
operationalRecordId: governedActionAttentionID,
subjectResourceId: governedResourceID,
subjectResourceName: "API container",
subjectResourceType: "app-container",
kind: "docker-container-health",
title: "API container is unhealthy",
plainLanguageSummary:
"Docker reported that the API container is unhealthy from a current health check.",
severity: "critical",
state: "open",
firstObservedAt,
lastObservedAt,
evidenceFreshness: "fresh",
evidenceCompleteness: "complete",
impact: "Requests handled by this container may fail.",
relatedResources: [],
recommendedNextStep:
"Review the bounded restart and its current policy before approving it.",
availableActions: [
governedActionOffer(
actionState === "unplanned" ? undefined : governedActionID,
),
],
verificationState:
actionState === "unplanned"
? "not_available"
: actionState === "completed"
? verification === "confirmed"
? "succeeded"
: "failed"
: "pending",
});
const governedActionAudit = (
actionState: "pending_approval" | "approved" | "completed",
verification: GovernedActionVerification,
) => {
const scope = {
orgId: "default",
resourceId: governedResourceID,
capabilityName: "restart",
};
const evidence = {
version: 1,
id: "restart-readback",
observerId: "docker-agent-1",
observerKind: "unified_agent",
observerTrustDomain: "agent:docker-agent-1",
executorTrustDomain: "agent:docker-agent-1",
method: "typed_container_read_after_write",
subjectId: governedResourceID,
observedAt: lastObservedAt,
receivedAt: evaluatedAt,
summary:
verification === "confirmed"
? "The same container was observed running."
: "The container was still not running after the provider returned success.",
digest: `sha256:${"a".repeat(64)}`,
};
return {
id: governedActionID,
createdAt: firstObservedAt,
updatedAt: evaluatedAt,
state: actionState,
decisionRevision: actionState === "pending_approval" ? 0 : 1,
request: {
requestId: `operational-trust:${governedActionAttentionID}:restart`,
resourceId: governedResourceID,
capabilityName: "restart",
params: {},
reason:
"Restart API container after fresh confirmed evidence reported an unhealthy container.",
requestedBy: "operator",
},
resource: {
id: governedResourceID,
name: "API container",
type: "app-container",
},
plan: {
actionId: governedActionID,
requestId: `operational-trust:${governedActionAttentionID}:restart`,
allowed: true,
requiresApproval: true,
approvalPolicy: "admin",
approvalRequirement: {
version: 1,
floor: "admin",
quorum: 1,
disallowRequester: false,
},
predictedBlastRadius: [governedResourceID],
rollbackAvailable: false,
plannedAt: firstObservedAt,
expiresAt: "2099-07-19T12:00:00Z",
resourceVersion: "resource:sha256:docker-health",
policyVersion: "policy:sha256:docker-restart",
planHash: `sha256:${"b".repeat(64)}`,
policyDecision: {
version: 1,
status: "resolved",
decisionId: "policy-decision:docker-restart",
actionId: governedActionID,
scope,
approvalRequirement: {
version: 1,
floor: "admin",
quorum: 1,
disallowRequester: false,
},
planningAllowed: true,
requiresApproval: true,
authorities: [
{
kind: "capability_registry",
sourceId: "capability-registry:restart",
revision: "policy:sha256:docker-restart",
status: "consulted",
scope,
approvalFloor: "admin",
reasonCodes: [
"capability_approval_admin",
"capability_auto_low_risk",
],
},
],
},
preflight: {
target: governedResourceID,
currentState: "warning",
intendedChange: "Restart this Docker container.",
dryRunAvailable: false,
safetyChecks: [
"The container still declares restart.",
"The reporting agent remains connected.",
],
verificationSteps: [
"Read the same container after the restart and compare its running state.",
],
generatedAt: firstObservedAt,
},
},
origin: {
surface: "operational_trust_attention",
operationalRecordId: governedActionAttentionID,
evidenceIds: ["docker-health-evidence"],
},
approvals:
actionState === "pending_approval"
? []
: [
{
actor: "operator",
method: "session",
timestamp: evaluatedAt,
outcome: "approved",
},
],
...(actionState === "completed"
? {
result: {
success: true,
actionResultV2: {
version: 2,
execution: {
status: "succeeded",
summary: "The provider accepted and completed the restart.",
},
verification: {
status: verification,
evidenceClass: "agent_attested",
...(verification === "contradicted"
? { reasonCode: "postcondition_contradicted" }
: {}),
summary: evidence.summary,
evidence: [evidence],
},
compensation: {
support: "unavailable",
status: "not_available",
summary: "Container restart is not rollbackable.",
},
},
},
verificationOutcome: {
status: verification === "confirmed" ? "verified" : "failed",
evidenceSummary: evidence.summary,
},
}
: { verificationOutcome: { status: "unknown" } }),
};
};
async function mockGovernedAttentionAction(
page: Page,
verification: GovernedActionVerification,
) {
await mockAttention(page, "active");
let actionState: "unplanned" | "pending_approval" | "approved" | "completed" =
"unplanned";
let executeCalls = 0;
await page.route("**/api/ai/patrol/attention**", async (route) => {
const url = new URL(route.request().url());
if (route.request().method() === "POST" && url.pathname.endsWith("/plan")) {
actionState = "pending_approval";
const audit = governedActionAudit(actionState, verification);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(audit.plan),
});
return;
}
const item = governedActionAttentionItem(actionState, verification);
if (url.pathname.endsWith("/summary")) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
activeCount: 1,
openCount: 1,
acknowledgedCount: 0,
suppressedCount: 0,
uncertainCount: 0,
resolvedCount: 0,
calm: false,
coverageState: "current",
evaluatedAt,
}),
});
return;
}
if (url.pathname !== "/api/ai/patrol/attention") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
item,
operationalRecord: {
id: governedActionAttentionID,
canonicalSpecId: "docker-container-health",
subjectResourceId: governedResourceID,
state: "open",
severity: "critical",
firstObservedAt,
lastObservedAt,
stateChangedAt: firstObservedAt,
evidenceIds: ["docker-health-evidence"],
causeKey: "docker-container-health",
relatedResourceIds: [],
impactSummary: item.impact,
recommendedNextStep: item.recommendedNextStep,
},
timeline: [],
evidence: [
{
id: "docker-health-evidence",
source: {
provider: "docker",
collector: "docker-container-health",
},
subject: { resourceId: governedResourceID },
observedAt: lastObservedAt,
ingestedAt: lastObservedAt,
validUntil: new Date(Date.now() + 5 * 60_000).toISOString(),
completeness: "complete",
confidence: "confirmed",
permissions: "sufficient",
},
],
}),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: [item],
summary: {
activeCount: 1,
openCount: 1,
acknowledgedCount: 0,
suppressedCount: 0,
uncertainCount: 0,
resolvedCount: 0,
calm: false,
coverageState: "current",
evaluatedAt,
},
meta: { page: 1, limit: 50, total: 1, totalPages: 1 },
}),
});
});
await page.route("**/api/actions/**", async (route) => {
const url = new URL(route.request().url());
if (url.pathname.endsWith("/decision")) {
actionState = "approved";
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
actionId: governedActionID,
state: actionState,
approval: { outcome: "approved" },
audit: governedActionAudit(actionState, verification),
}),
});
return;
}
if (url.pathname.endsWith("/execute")) {
executeCalls += 1;
actionState = "completed";
const audit = governedActionAudit(actionState, verification);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
actionId: governedActionID,
state: actionState,
result: audit.result,
audit,
}),
});
return;
}
const audit = governedActionAudit(
actionState === "unplanned" ? "pending_approval" : actionState,
verification,
);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
audit,
events: [],
...(actionState === "completed"
? {
attempt: {
id: `${governedActionID}.dispatch.1`,
actionId: governedActionID,
state: "receipt_recorded",
createdAt: firstObservedAt,
updatedAt: evaluatedAt,
dispatchCount: 1,
},
receipt: {
attemptId: `${governedActionID}.dispatch.1`,
actionId: governedActionID,
transportRequestId: `${governedActionID}.dispatch.1`,
receivedAt: evaluatedAt,
},
}
: {}),
}),
});
});
return {
executeCalls: () => executeCalls,
};
}
for (const verification of [
"confirmed",
"contradicted",
] as GovernedActionVerification[]) {
test(`runs the governed restart and represents ${verification} verification honestly`, async ({
page,
}) => {
if (verification === "contradicted") {
await page.setViewportSize({ width: 390, height: 844 });
await page.emulateMedia({ reducedMotion: "reduce" });
}
const fixture = await mockGovernedAttentionAction(page, verification);
await page.goto("/patrol", { waitUntil: "domcontentloaded" });
const itemButton = page.getByRole("button", {
name: "Open API container is unhealthy",
});
await itemButton.focus();
await page.keyboard.press("Enter");
const detailPanel = page.getByRole("complementary", {
name: "API container is unhealthy",
});
await expect(detailPanel).toBeVisible();
await expect(
detailPanel.getByText(
"The same container is observed running after the restart.",
),
).toBeVisible();
await expect(
detailPanel.getByText(
/explicit review and approval before Pulse sends anything/i,
),
).toBeVisible();
const reviewTrigger = detailPanel.getByRole("button", {
name: "Review and approve",
});
await reviewTrigger.click();
const dialog = page.getByRole("dialog", { name: "Restart" });
await expect(dialog).toBeVisible();
await expect(
dialog.getByText(
"Restart API container after fresh confirmed evidence reported an unhealthy container.",
),
).toBeVisible();
await dialog.getByText("Policy evidence", { exact: true }).click();
await expect(
dialog.getByText("Eligible for low-risk automation"),
).toBeVisible();
await dialog.getByRole("button", { name: "Approve" }).click();
await expect(
dialog.getByRole("button", { name: "Run action" }),
).toBeVisible();
await dialog.getByRole("button", { name: "Run action" }).click();
await expect(
dialog.getByRole("button", { name: "Run action" }),
).toHaveCount(0);
await dialog.getByRole("button", { name: "Close", exact: true }).click();
await expect(
detailPanel.getByRole("button", { name: "Review action" }),
).toBeFocused();
expect(fixture.executeCalls()).toBe(1);
await expect(
detailPanel.getByText(/recorded the action result below/i),
).toBeVisible();
await expect(
detailPanel.getByText(
/explicit review and approval before Pulse sends anything/i,
),
).toHaveCount(0);
if (verification === "confirmed") {
await expect(
detailPanel.getByText(/restart postcondition was confirmed/i),
).toBeVisible();
await expect(
detailPanel.getByText(/issue stays open until fresh health evidence/i),
).toBeVisible();
} else {
await expect(
detailPanel.getByText(/restart did not satisfy its postcondition/i),
).toBeVisible();
await expect(detailPanel.getByText(/issue remains open/i)).toBeVisible();
const overflows = await page.evaluate(
() =>
document.documentElement.scrollWidth >
document.documentElement.clientWidth,
);
expect(overflows).toBeFalsy();
}
});
}