From cd75722da3a6258b8090b7d7252db3c6db30fa4f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sun, 19 Jul 2026 05:45:58 +0100 Subject: [PATCH] Build canonical Operational Trust action loop Refs #1034 --- ...rust-governed-docker-restart-2026-07-19.md | 208 +++++++ docs/release-control/v6/internal/status.json | 5 + .../v6/internal/subsystems/agent-lifecycle.md | 33 +- .../v6/internal/subsystems/ai-runtime.md | 17 + .../v6/internal/subsystems/api-contracts.md | 23 + .../subsystems/frontend-primitives.md | 11 + .../subsystems/patrol-intelligence.md | 18 + .../subsystems/performance-and-scalability.md | 9 + .../internal/subsystems/security-privacy.md | 18 + .../internal/subsystems/storage-recovery.md | 10 + .../internal/subsystems/unified-resources.md | 21 +- .../src/api/__tests__/patrolAttention.test.ts | 13 + frontend-modern/src/api/patrolAttention.ts | 39 +- .../patrol/PatrolAttentionWorkbench.tsx | 197 +++++-- .../PatrolAttentionWorkbench.test.tsx | 121 +++- internal/ai/attention.go | 20 +- internal/ai/attention_actions.go | 237 ++++++++ internal/ai/attention_actions_test.go | 224 ++++++++ internal/api/attention_actions.go | 240 ++++++++ internal/api/attention_actions_test.go | 289 ++++++++++ internal/api/attention_handlers.go | 34 +- internal/api/attention_handlers_test.go | 11 + internal/api/contract_test.go | 46 ++ internal/api/router.go | 1 + internal/unifiedresources/actions.go | 29 +- internal/unifiedresources/store.go | 168 ++++++ internal/unifiedresources/store_test.go | 38 +- ...rational-trust-attention-workbench.spec.ts | 519 +++++++++++++++++- 28 files changed, 2505 insertions(+), 94 deletions(-) create mode 100644 docs/release-control/v6/internal/records/operational-trust-governed-docker-restart-2026-07-19.md create mode 100644 internal/ai/attention_actions.go create mode 100644 internal/ai/attention_actions_test.go create mode 100644 internal/api/attention_actions.go create mode 100644 internal/api/attention_actions_test.go diff --git a/docs/release-control/v6/internal/records/operational-trust-governed-docker-restart-2026-07-19.md b/docs/release-control/v6/internal/records/operational-trust-governed-docker-restart-2026-07-19.md new file mode 100644 index 000000000..2ac083369 --- /dev/null +++ b/docs/release-control/v6/internal/records/operational-trust-governed-docker-restart-2026-07-19.md @@ -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. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 1d8e456fc..8c45bc933 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -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", diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index e95d7345a..ed243644a 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 2b24dfbeb..fb96d6525 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index bd9208559..1952172b2 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 3c8cf3b51..163950d04 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 181d8b916..f941539ac 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index c041e5608..b43c6ade1 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index adc5a4ad5..3f16f7698 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index ffb97cccf..64f837911 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index d341e1cae..7ab89279c 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -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 diff --git a/frontend-modern/src/api/__tests__/patrolAttention.test.ts b/frontend-modern/src/api/__tests__/patrolAttention.test.ts index a428541ae..1e4f017f6 100644 --- a/frontend-modern/src/api/__tests__/patrolAttention.test.ts +++ b/frontend-modern/src/api/__tests__/patrolAttention.test.ts @@ -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: '{}', + }, + ); + }); }); diff --git a/frontend-modern/src/api/patrolAttention.ts b/frontend-modern/src/api/patrolAttention.ts index 73e0ed809..b1c957a32 100644 --- a/frontend-modern/src/api/patrolAttention.ts +++ b/frontend-modern/src/api/patrolAttention.ts @@ -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 { + return apiFetchJSON( + `/api/ai/patrol/attention/${encodeURIComponent(itemId)}/actions/${encodeURIComponent(capability)}/plan`, + { + method: 'POST', + body: '{}', + }, + ); +} diff --git a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx index 8b2bc282f..d3b1f2607 100644 --- a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx +++ b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx @@ -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(null); + const [actionBusy, setActionBusy] = createSignal(false); + const [actionError, setActionError] = createSignal(''); const itemButtons = new Map(); 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( + '[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() { -
+
{(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} />
+ ); } @@ -262,10 +322,7 @@ function AttentionList(props: { } > - 0} - fallback={} - > + 0} fallback={}>
    {(item) => ( @@ -303,7 +360,10 @@ function AttentionList(props: { {formatRelativeTime(item.firstObservedAt, { compact: true })} -