From 66dc5fd7dfe0b68d7a1ad5a91bebafd52b5e6eca Mon Sep 17 00:00:00 2001 From: rcourtman Date: Mon, 13 Jul 2026 17:00:36 +0100 Subject: [PATCH] Polish Actions layout and review details --- .../v6/internal/subsystems/agent-lifecycle.md | 6 + .../v6/internal/subsystems/api-contracts.md | 8 +- .../subsystems/frontend-primitives.md | 12 +- .../internal/subsystems/storage-recovery.md | 7 + .../internal/subsystems/unified-resources.md | 20 +- .../src/api/__tests__/actionAudit.test.ts | 11 + .../SharedPrimitives.guardrails.test.ts | 1 + .../features/actions/ActionDecisionPacket.tsx | 372 +++++++-- .../features/actions/ActionReviewDialog.tsx | 13 +- .../__tests__/ActionDecisionPacket.test.tsx | 331 ++++++-- .../actionPresentation.branchcov0713.test.ts | 10 + .../features/actions/actionPresentation.ts | 26 +- .../__tests__/DockerNativeTables.test.tsx | 2 +- frontend-modern/src/pages/Actions.tsx | 5 +- frontend-modern/src/types/actionAudit.ts | 8 + internal/api/actions.go | 98 ++- internal/api/actions_mock_test.go | 6 + internal/api/actions_test.go | 54 ++ internal/api/contract_test.go | 49 ++ .../tests/81-actions-inbox.spec.ts | 722 +++++++++++++++--- 20 files changed, 1509 insertions(+), 252 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index f0de196cf..b6c443eb5 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -414,6 +414,12 @@ same read routes, the response is explicitly `readOnly` and the rows remain inspection fixtures only. Agent lifecycle surfaces must remove decision and execution affordances, must not treat a mock approval as an agent command grant, and must not infer lifecycle reachability from fixture state. +Those list/detail reads may also attach the canonical unified-resource name and +contract type as a sibling `resource` presentation object. Agent lifecycle +consumers must treat that object as display metadata only: the exact +`request.resourceId` remains the command target, and resource display changes +must not alter the action ID, plan hash, approval binding, dispatch identity, or +durable receipt reconciliation. The investigation continuity reconciler in `internal/api/patrol_action_reconciliation.go` is also API-owned: callbacks only wake an authoritative action-audit re-read, diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 9c64d6abe..81ed66573 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -7355,7 +7355,13 @@ dispatch attempt, and correlated receipt. Decisions and execution remain inventory and router allowlist, and queue failures use the shared `agentcapabilities` vocabulary. Pending rows are oldest-first and expose the same action audit shape, including requester and origin, used by desktop and -mobile. The mobile client must approve by recording an approved decision and +mobile. Action list and detail audits may add a `resource` read projection with +the canonical unified-resource `id`, `name`, and contract `type`. That object is +resolved at read time and is presentation metadata only: it must stay outside +`ActionRequest`, persistence, action ID, plan hash, decision binding, and +execution authority. An unavailable or superseded resource therefore omits the +projection without making the durable audit unreadable. The mobile client must +approve by recording an approved decision and must not treat that decision as execution. A later explicit Run action gesture, with its own local authentication gate, calls execute. Interactive clients bind both requests to the exact reviewed plan by sending `planHash`; the REST fields diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 019e58f75..776985a0b 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -635,9 +635,15 @@ Global Actions review uses the canonical shared `Dialog`, `Button`, `Subtabs`, selector is an in-page sub-navigation surface and must compose `Subtabs` instead of recreating a segmented tablist in `Actions.tsx`; queue rows may own action state and resource semantics, but their frame and badge chrome stay on -the shared primitives. The responsive route must preserve named tabs, keyboard -focus, dialog focus containment, and phone-width overflow checks; journey 83 -is the desktop/browser accessibility proof and is not mobile-device proof. +the shared primitives. Actions uses the full content width supplied by the app +shell, matching Patrol instead of adding a page-local maximum-width container. +The review's policy provenance uses a native keyboard-operable disclosure so +the initial dialog layer stays calm without removing audit detail; intent, +exact target identity, safety/authority posture, and fail-closed provenance +warnings remain visible before expansion. The responsive route must preserve +named tabs, keyboard focus, dialog focus containment, and phone-width overflow +checks; journey 83 is the desktop/browser accessibility proof and is not +mobile-device proof. Assistant shell entry changes must keep Assistant contextual rather than generic: `AppLayout.tsx` and the command palette may expose a compact launcher, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 16cd3a6dc..04dec7d65 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -406,6 +406,13 @@ scope overlap as storage or recovery ownership. A Docker workload that also belongs to an owning platform remains governed by the resource, policy, and backup capability contracts exposed by unified resources and the shared API boundary. +Action list/detail reads may attach the current canonical resource name and +contract type as a sibling `resource` presentation object. Storage and recovery +consumers may show that metadata, but restore or remediation authority remains +bound to `request.resourceId`, the reviewed plan hash, and the durable action +record. A resource rename or an unavailable presentation projection must never +retarget execution, invalidate recovery evidence, or make an existing audit +unreadable. Successful action plans also remain API-owned audit facts before storage/recovery surfaces consume them: approval-required plans must persist as `pending_approval` with initial lifecycle evidence, and retry/idempotency diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index a24ce8a3e..08a7d0ccb 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -1743,11 +1743,21 @@ The Actions inbox presents that durable record as a compact operator queue, not a stack of equally weighted audit cards. Open work orders approval-required decisions before runnable and executing actions; each collapsed row exposes only state, action, bounded resource identity, recency, and reason before the -operator opens the governed review. Opaque canonical resource IDs remain in -the row's accessible name and title but are visually demoted to a type plus -short suffix. Read-only demo posture is quiet supporting context rather than a -page-level callout. The governed review dialog continues to own the full exact -resource ID, plan, policy evidence, lifecycle, authority, and outcome truth. +operator opens the governed review. Action list and detail reads enrich each +audit with the current canonical resource name and contract type when that +resource can be resolved. This metadata is a read-time presentation projection, +not part of `ActionRequest`, durable plan identity, or `planHash`; a resource +rename therefore cannot change action authority. Opaque canonical resource IDs +remain in the row's accessible name and title, while the visual row prefers the +API-supplied name and type and falls back to the bounded ID-derived type plus +short suffix only when the resource projection is unavailable. Read-only demo +posture is quiet supporting context rather than a page-level callout. The +governed review dialog continues to own the full exact resource ID, plan, +policy evidence, lifecycle, authority, and outcome truth. Its first layer keeps +intent plus safety/authority facts visible while the immutable planning-time +policy sources, revisions, and reason codes sit behind an explicit disclosure; +missing provenance remains an immediate fail-closed warning rather than hidden +detail. APT review presents server-recorded policy provenance and distinguishes the elevated update posture from low-risk-eligible cache cleanup. Both typed actions diff --git a/frontend-modern/src/api/__tests__/actionAudit.test.ts b/frontend-modern/src/api/__tests__/actionAudit.test.ts index cc92924fa..8010dc596 100644 --- a/frontend-modern/src/api/__tests__/actionAudit.test.ts +++ b/frontend-modern/src/api/__tests__/actionAudit.test.ts @@ -190,6 +190,11 @@ describe('ActionAuditAPI', () => { reason: 'Recover the edge proxy', requestedBy: 'pulse_patrol', }, + resource: { + id: scope.resourceId, + name: 'Edge proxy', + type: 'app-container', + }, plan: { actionId: 'action/one', requestId: 'request-1', @@ -242,6 +247,12 @@ describe('ActionAuditAPI', () => { expect(apiFetchJSONMock).toHaveBeenNthCalledWith(2, '/api/actions?view=settled&limit=25'); expect(apiFetchJSONMock).toHaveBeenNthCalledWith(3, '/api/actions/action%2Fone'); expect(detail.audit.plan.policyDecision).toEqual(policyDecision); + expect(detail.audit.resource).toEqual({ + id: 'docker:container:web', + name: 'Edge proxy', + type: 'app-container', + }); + expect(detail.audit.request).not.toHaveProperty('resourceName'); expect(detail.readOnly).toBe(true); expect(detail.audit.result?.actionResultV2).toMatchObject({ execution: { status: 'succeeded' }, diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index c1d5a00a9..0fadb51d0 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -1166,6 +1166,7 @@ describe('shared primitive guardrails', () => { it('keeps the Actions inbox on the canonical in-page subtab primitive', () => { expect(actionsSource).toContain(' { diff --git a/frontend-modern/src/features/actions/ActionDecisionPacket.tsx b/frontend-modern/src/features/actions/ActionDecisionPacket.tsx index 467e5c878..abdb6ccfa 100644 --- a/frontend-modern/src/features/actions/ActionDecisionPacket.tsx +++ b/frontend-modern/src/features/actions/ActionDecisionPacket.tsx @@ -1,19 +1,27 @@ import { For, Show, createMemo, type Component } from 'solid-js'; +import ChevronDownIcon from 'lucide-solid/icons/chevron-down'; import type { ActionAuditRecord, ActionDetailResponse } from '@/types/actionAudit'; import { formatActionName, formatEvidenceClass, formatPolicyAuthority, formatPolicyReason, + getActionResourcePresentation, verificationTruthLabel, } from './actionPresentation'; import { getAPTActionPresentation } from './aptActionPresentation'; -export const ActionDecisionPacket: Component<{ audit: ActionAuditRecord; detail?: ActionDetailResponse }> = (props) => { +export const ActionDecisionPacket: Component<{ + audit: ActionAuditRecord; + detail?: ActionDetailResponse; +}> = (props) => { const policy = () => props.audit.plan.policyDecision; const result = () => props.audit.result?.actionResultV2; const apt = () => getAPTActionPresentation(props.audit); const firstEvidence = () => result()?.verification.evidence?.[0]; + const resource = createMemo(() => + getActionResourcePresentation(props.audit.request.resourceId, props.audit.resource), + ); const expiry = createMemo(() => { const value = new Date(props.audit.plan.expiresAt ?? ''); return Number.isNaN(value.valueOf()) ? 'Not recorded' : value.toLocaleString(); @@ -21,108 +29,342 @@ export const ActionDecisionPacket: Component<{ audit: ActionAuditRecord; detail? return (
-
-

What will happen

+
+

+ What will happen +

-
Action
{formatActionName(props.audit.request.capabilityName)}
-
Resource
{props.audit.request.resourceId}
-
Reason
{props.audit.request.reason}
-
Current state
{props.audit.plan.preflight?.currentState}
-
Intended change
{props.audit.plan.preflight?.intendedChange}
-
Approval expires
{expiry()}
-
Rollback declared
{props.audit.plan.rollbackAvailable ? 'Yes' : 'No'}
+
+
Action
+
{formatActionName(props.audit.request.capabilityName)}
+
+
+
Resource
+
{resource().label}
+
{props.audit.request.resourceId}
+
+
+
Reason
+
{props.audit.request.reason}
+
+ +
+
Current state
+
{props.audit.plan.preflight?.currentState}
+
+
+ +
+
Intended change
+
{props.audit.plan.preflight?.intendedChange}
+
+
+
+
Approval expires
+
{expiry()}
+
+
+
Rollback declared
+
{props.audit.plan.rollbackAvailable ? 'Yes' : 'No'}
+
0}> -
Also affected
    {(resource) =>
  • {resource}
  • }
+
+
Also affected
+
    + + {(resource) =>
  • {resource}
  • } +
    +
+
{(presentation) => ( -
-

Safety and authority

+
+

+ Safety and authority +

-
Risk posture
{presentation().safetyPosture}
-
Decision required
{presentation().approvalPosture}
-
Operator-selected parameters
{presentation().parameterAuthority}
-
What this action can do
{presentation().authorityBoundary}
+
+
Risk posture
+
{presentation().safetyPosture}
+
+
+
Decision required
+
{presentation().approvalPosture}
+
+
+
Operator-selected parameters
+
{presentation().parameterAuthority}
+
+
+
What this action can do
+
{presentation().authorityBoundary}
+
)} -
-

Why Pulse allows this review

- This older action has no server-recorded policy provenance. Re-plan it before acting.

}> -

Server decision {policy()?.decisionId}

-
- - {(authority) => ( -
-
- {formatPolicyAuthority(authority)} - {authority.status === 'consulted' ? 'Consulted' : formatActionName(authority.status)} + +

+ Policy evidence unavailable +

+

+ This older action has no server-recorded policy provenance. Re-plan it before acting. +

+
+ } + > +
+ + + + Policy evidence + + + {policy()?.authorities.length ?? 0}{' '} + {(policy()?.authorities.length ?? 0) === 1 ? 'authority' : 'authorities'} checked at + planning. Pulse checks current authority again before execution. + + + +
+

Server decision {policy()?.decisionId}

+
+ + {(authority) => ( +
+
+ {formatPolicyAuthority(authority)} + + {authority.status === 'consulted' + ? 'Consulted' + : formatActionName(authority.status)} + +
+
+ {authority.sourceId} + · {authority.revision} +
+
    + + {(reason) =>
  • {formatPolicyReason(reason)}
  • } +
    +
-
{authority.sourceId} · {authority.revision}
-
    {(reason) =>
  • {formatPolicyReason(reason)}
  • }
-
- )} - + )} + +
+

+ This is the immutable planning-time policy record. It does not authorize execution by + itself. +

-

This records planning-time policy evidence. Pulse checks current authority again before execution.

- - + + {(truth) => (
-

Recorded outcome

+

+ Recorded outcome +

-
-
Agent-reported facts
+
+
+ Agent-reported facts +
- {(fact) =>
{fact.label}
{fact.value}
}
+ + {(fact) => ( +
+
{fact.label}
+
{fact.value}
+
+ )} +
-
+
Execution
-
{truth().execution.status === 'not_run' ? 'Did not run' : formatActionName(truth().execution.status)}
-
Reason: {formatActionName(truth().execution.reasonCode!)}
-

{truth().execution.summary}

-
-
-
Verification
-
{verificationTruthLabel(truth().verification.status, truth().verification.evidenceClass)}
-
Source: {formatEvidenceClass(truth().verification.evidenceClass)}
-
Reason: {formatActionName(truth().verification.reasonCode!)}
-

{truth().verification.summary}

- 0}> -
Evidence details
    {(evidence) =>
  • {apt() ? 'Typed read-after-write observation' : evidence.summary || evidence.method}
    Observed by {evidence.observerId} · {evidence.observerTrustDomain}
    Agent observed {new Date(evidence.observedAt).toLocaleString()} · Pulse received {new Date(evidence.receivedAt).toLocaleString()}
  • }
+
+ {truth().execution.status === 'not_run' + ? 'Did not run' + : formatActionName(truth().execution.status)} +
+ +
+ Reason: {formatActionName(truth().execution.reasonCode!)} +
+
+ +

{truth().execution.summary}

-
+
+
+ Verification +
+
+ {verificationTruthLabel( + truth().verification.status, + truth().verification.evidenceClass, + )} +
+
+ Source: {formatEvidenceClass(truth().verification.evidenceClass)} +
+ +
+ Reason: {formatActionName(truth().verification.reasonCode!)} +
+
+ +

{truth().verification.summary}

+
+ 0}> +
+ Evidence details +
    + + {(evidence) => ( +
  • +
    + {apt() + ? 'Typed read-after-write observation' + : evidence.summary || evidence.method} +
    +
    + Observed by {evidence.observerId} · {evidence.observerTrustDomain} +
    +
    + Agent observed {new Date(evidence.observedAt).toLocaleString()} · Pulse + received {new Date(evidence.receivedAt).toLocaleString()} +
    +
  • + )} +
    +
+
+
+
+
Recovery
{formatActionName(truth().compensation.status)}
-
Support: {formatActionName(truth().compensation.support)}
-
Strategy: {truth().compensation.strategy}
-

{truth().compensation.summary}

+
+ Support: {formatActionName(truth().compensation.support)} +
+ +
Strategy: {truth().compensation.strategy}
+
+ +

{truth().compensation.summary}

+
- {(presentation) =>
What to do next

{presentation().nextStep}

}
+ + {(presentation) => ( +
+
What to do next
+

{presentation().nextStep}

+
+ )} +
)}
-
-

Durable delivery record

-

{props.detail?.receipt ? 'One agent receipt is recorded for this action.' : props.detail?.attempt?.state === 'receipt_pending' ? 'The action was sent once and Pulse is waiting for the durable agent receipt.' : 'Pulse recorded the delivery attempt before sending it.'}

-

Refreshing or reconnecting re-reads this action record; it does not create another action.

+
+

+ Durable delivery record +

+

+ {props.detail?.receipt + ? 'One agent receipt is recorded for this action.' + : props.detail?.attempt?.state === 'receipt_pending' + ? 'The action was sent once and Pulse is waiting for the durable agent receipt.' + : 'Pulse recorded the delivery attempt before sending it.'} +

+

+ Refreshing or reconnecting re-reads this action record; it does not create another + action. +

-
Agent observation
{new Date(firstEvidence()!.observedAt).toLocaleString()}
-
Receipt recorded by Pulse
{new Date(props.detail!.receipt!.receivedAt).toLocaleString()}
+ +
+
Agent observation
+
{new Date(firstEvidence()!.observedAt).toLocaleString()}
+
+
+ +
+
Receipt recorded by Pulse
+
{new Date(props.detail!.receipt!.receivedAt).toLocaleString()}
+
+
-
Delivery identifiers
Action {props.audit.id} · Attempt {props.detail?.attempt?.id} · Transport {props.detail?.receipt?.transportRequestId}
+
+ Delivery identifiers +
+ Action {props.audit.id} + · Attempt {props.detail?.attempt?.id} + + {' '} + · Transport {props.detail?.receipt?.transportRequestId} + +
+
diff --git a/frontend-modern/src/features/actions/ActionReviewDialog.tsx b/frontend-modern/src/features/actions/ActionReviewDialog.tsx index 5d20d80bc..2175a7c8d 100644 --- a/frontend-modern/src/features/actions/ActionReviewDialog.tsx +++ b/frontend-modern/src/features/actions/ActionReviewDialog.tsx @@ -7,7 +7,7 @@ import { notificationStore } from '@/stores/notifications'; import { presentationPolicyIsReadOnly } from '@/stores/sessionPresentationPolicy'; import type { ActionDetailResponse } from '@/types/actionAudit'; import { ActionDecisionPacket } from './ActionDecisionPacket'; -import { formatActionName } from './actionPresentation'; +import { formatActionName, getActionResourcePresentation } from './actionPresentation'; import { getAPTActionPresentation } from './aptActionPresentation'; export const ActionReviewDialog: Component<{ @@ -19,6 +19,12 @@ export const ActionReviewDialog: Component<{ const [error, setError] = createSignal(''); const [clock, setClock] = createSignal(Date.now()); const audit = () => props.detail?.audit; + const resource = createMemo(() => { + const record = audit(); + return record + ? getActionResourcePresentation(record.request.resourceId, record.resource) + : { label: '', detail: '' }; + }); const readOnly = createMemo( () => props.detail?.readOnly === true || presentationPolicyIsReadOnly(), ); @@ -155,7 +161,10 @@ export const ActionReviewDialog: Component<{

{formatActionName(record().request.capabilityName)}

-

{record().request.resourceId}

+

+ {resource().label} + · {resource().detail} +