fix(actions): gate approvals on live readiness

This commit is contained in:
rcourtman
2026-08-07 17:02:21 +01:00
parent 8fd43b307b
commit 0928071b9a
32 changed files with 932 additions and 147 deletions
+4 -4
View File
@@ -249,7 +249,7 @@ the live set.
**Actions (governed plan/approval/execute):**
- `plan_action` (Plan action, `POST /api/actions/plan`, scope `actions:plan`, mode `write`, approval `action_plan`): Plan an action against a resource. The planner validates the request, looks up the capability on the resource, checks executor-owned live availability, and returns an ActionPlan with the approval policy, blast radius, plan hash, and preflight summary. The plan is persisted to the audit history at the planned/pending state only after the live availability check passes, so subsequent decide_action and execute_action calls can reference it by id. Plan-and-execute is a two-step flow when the resulting plan requires approval, one-step otherwise.
- `decide_action` (Decide action, `POST /api/actions/{actionId}/decision`, scope `actions:approve`, mode `write`, approval `action_plan`): Record an approval decision (approved or rejected) on a previously planned action. The actor is taken from the authenticated identity; an explicit reason can be passed in the body. An exact retry returns the authoritative persisted decision without adding an approval or lifecycle event; a conflicting retry fails closed.
- `decide_action` (Decide action, `POST /api/actions/{actionId}/decision`, scope `actions:approve`, mode `write`, approval `action_plan`): Record an approval decision (approved or rejected) on a previously planned action. The actor is taken from the authenticated identity; an explicit reason can be passed in the body. Approval rechecks current expiry, policy, executor, resource-contract, and executor-owned availability gates before persistence, while rejection remains available when readiness is false. An exact retry returns the authoritative persisted decision without adding an approval or lifecycle event; a conflicting retry fails closed.
- `execute_action` (Execute action, `POST /api/actions/{actionId}/execute`, scope `actions:execute`, mode `write`, approval `action_plan`): Execute a previously planned and (when required) approved action. Returns the persisted audit record with the execution result attached. Refuses with stable codes when the action is in the wrong lifecycle state (action_not_approved, action_already_executing, action_execution_final, action_dry_run_only, action_plan_expired), when executor-owned live readiness is no longer available (action_execution_unavailable), when the approved plan no longer matches the current resource/capability contract (action_plan_drift), when the target is operator-locked against automated remediation (resource_remediation_locked), or when the API instance has no executor wired (action_executor_unavailable). Both human and automatic policy execution recheck readiness before dispatch admission. action.completed SSE events fire on every terminal state so agents watching the stream do not need to poll this endpoint after dispatch.
**Provisioning (infrastructure onboarding):**
@@ -319,9 +319,9 @@ Capability-specific stable codes are advertised by the manifest:
- `snooze_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`
- `dismiss_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`
- `resolve_finding`: `invalid_finding_request`, `finding_not_found`, `finding_action_not_allowed`, and `patrol_unavailable`
- `plan_action`: `invalid_action_request`, `mock_mode_enabled`, `action_actor_unavailable`, `resource_not_found`, `capability_not_found`, and `action_execution_unavailable`
- `decide_action`: `mock_mode_enabled`, `missing_id`, `invalid_id`, `invalid_action_decision`, `action_not_found`, `action_not_pending`, `action_plan_expired`, `action_plan_identity_mismatch`, `action_actor_unavailable`, `action_approval_forbidden`, `action_step_up_unavailable`, `action_decision_conflict`, `action_separation_required`, and `action_replan_required`
- `execute_action`: `mock_mode_enabled`, `missing_id`, `invalid_id`, `invalid_action_execution`, `action_not_found`, `action_not_approved`, `action_already_executing`, `action_execution_final`, `action_dry_run_only`, `action_plan_expired`, `action_execution_unavailable`, `action_plan_drift`, `action_plan_identity_mismatch`, `resource_remediation_locked`, `action_executor_unavailable`, `action_actor_unavailable`, `action_execution_forbidden`, `action_not_executing`, and `action_replan_required`
- `plan_action`: `invalid_action_request`, `mock_mode_enabled`, `action_actor_unavailable`, `resource_not_found`, `capability_not_found`, `action_execution_unavailable`, and `action_refresh_not_allowed`
- `decide_action`: `mock_mode_enabled`, `missing_id`, `invalid_id`, `invalid_action_decision`, `action_not_found`, `action_not_pending`, `action_plan_expired`, `action_plan_identity_mismatch`, `action_actor_unavailable`, `action_approval_forbidden`, `action_step_up_unavailable`, `action_decision_conflict`, `action_separation_required`, `action_replan_required`, `action_execution_unavailable`, `action_plan_drift`, `action_emergency_stop`, `action_dry_run_only`, `resource_remediation_locked`, `action_executor_unavailable`, and `action_execution_availability_failed`
- `execute_action`: `mock_mode_enabled`, `missing_id`, `invalid_id`, `invalid_action_execution`, `action_not_found`, `action_not_approved`, `action_already_executing`, `action_execution_final`, `action_dry_run_only`, `action_plan_expired`, `action_execution_unavailable`, `action_plan_drift`, `action_emergency_stop`, `action_plan_identity_mismatch`, `resource_remediation_locked`, `action_executor_unavailable`, `action_actor_unavailable`, `action_execution_forbidden`, `action_not_executing`, and `action_replan_required`
<!-- pulse-mcp-errors:end -->
Cross-cutting codes from the auth / multi-tenant middleware
@@ -0,0 +1,37 @@
# Action approval readiness and replacement-plan proof — 2026-08-07
## Decision
Pulse now applies every non-mutating dispatch-readiness gate before persisting a human approval and repeats the same check at dispatch. An operator may still reject a pending action when readiness is false. Expired or drifted plans can be replaced, but the replacement receives a new action identity and plan hash and must be reviewed and approved independently.
## Invariants
- Approval is not durable unless expiry, dry-run policy, emergency stop, executor wiring, resource/capability freshness, resource remediation policy, and executor-owned live availability all pass.
- A failed readiness check leaves the action pending and does not append an approval or lifecycle event.
- Executor-owned refusal reason and remediation remain exact through the lifecycle, API, and review dialog.
- Refresh is bound to the reviewed plan hash and is allowed only for expired or drifted plans.
- Patrol refresh reconstructs current policy inputs and the trusted Patrol service actor while preserving finding, investigation, and evidence origin.
- Refresh never copies approval and never mutates the old action into a new plan.
- Execution success alone does not become `fix_verified`; existing independent-evidence rules remain authoritative.
## Governed surfaces
- `internal/actionlifecycle/service.go`
- `internal/api/actions.go`
- `internal/api/patrol_action_broker.go`
- `internal/api/router_routes_monitoring.go`
- `internal/mutationregistry/manifest.json`
- `frontend-modern/src/features/actions/ActionReviewDialog.tsx`
- subsystem contracts for API, unified resources, AI runtime, agent lifecycle, and storage recovery
## Proof
- Backend lifecycle tests cover readiness loss before approval, rejection while blocked, no audit mutation, expired/drifted replacement identity, origin preservation, and refresh idempotency.
- API tests cover exact readiness errors, pending-state preservation, Patrol policy/origin reconstruction, plan-hash binding, stable agent error codes, mutation inventory, and route inventory.
- Frontend tests cover API refresh binding, exact refusal presentation, rejection availability, replacement handoff, and structured API error detail.
- Browser verification at 1440×1000 confirms the exact command-agent unblock, Reject present, and Approve absent.
- Browser verification at 390×844 confirms no document overflow and reachable Close, Refresh plan, and Reject controls.
- The browser refresh interaction returns a fresh ready replacement, displays the re-review notice, removes Refresh plan, and only then displays Approve.
- `status_audit.py --check` and `registry_audit.py --check` validate the final governed state.
The command results for the accepted commit are recorded in the task handoff and `frontend-modern/browser-verification.json` records the user-visible browser proof.
+6 -1
View File
@@ -6708,6 +6708,11 @@
"path": "cmd/pulse-mcp/main_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/action-approval-readiness-refresh-2026-08-07.md",
"kind": "file"
},
{
"repo": "pulse",
"path": "docs/release-control/v6/internal/records/action-audit-resource-history-surface-2026-04-29.md",
@@ -8955,7 +8960,7 @@
},
{
"id": "action-governance-auditability-post-rc-hardening",
"summary": "Task 07 durable delivery and generic agent-operation receipts are accepted: transactional dispatch admission, one-shot send CAS, immutable attempt/action/operation/digest/agent binding, query-only restart reconciliation, strict sanitized terminal envelopes, and permanent replay-denial tombstones. Task 09 consumes that owner for both APT workflows and completes the detector-to-finding, exact empty-parameter proposal, shared policy/approval, typed dispatch, delayed/callback-loss/reopened-server receipt reconciliation, ActionResultV2 truth, terminal audit, and finding reconciliation floor without blind resend. Task 11 adds the desktop/browser tier-5 Product Trust consumer: bounded command/path/package-free Patrol evidence; exact empty parameter and elevated-versus-low-risk review; separate execution, evidence-sourced verification, and recovery cards; agent-attested confirmed updates; reboot fact without reboot authority; partial/unknown-health recovery; measured irreversible cleanup; and one durable receipt across reconnect, all exercised in current-build Chromium and a 390-pixel viewport. Malformed phases, counts, timestamps, usage, or cleanup arithmetic fail closed. The closed RG-01 through RG-12 matrix now supplies the disposable Debian/Ubuntu tier-6, Docker, browser, physical-device, live-Relay, revocation, cleanup, and independent Task 12 proof that previously kept claims 16 and 17 and both workflow scorecards operationally open. Task 10 remains sole owner of execution, verification, evidence, compensation, and rollback truth, and RG06/RG09 product outcomes remain agent-attested fix_verification_unknown. Proxmox VM/LXC lifecycle now supplies the first production distinct-trust-domain verification path: the node agent executes, the tenant-scoped server Proxmox client observes status and uptime, reboot requires an uptime reset, and ActionResultV2 keeps execution success independent from postcondition contradiction. This follow-up now tracks only broader post-RC auditability beyond the proved bounded capability set, including additional operation and platform coverage, deeper compensation and rollback proof, and MSP or fleet aggregation; it does not reopen the accepted matrix. Raw model command, file write, arbitrary pod exec, legacy run_command, /api/ai/run-command, and enterprise command remediation remain retired with no replacement or executable historical authority.",
"summary": "Task 07 durable delivery and generic agent-operation receipts are accepted: transactional dispatch admission, one-shot send CAS, immutable attempt/action/operation/digest/agent binding, query-only restart reconciliation, strict sanitized terminal envelopes, and permanent replay-denial tombstones. Task 09 consumes that owner for both APT workflows and completes the detector-to-finding, exact empty-parameter proposal, shared policy/approval, typed dispatch, delayed/callback-loss/reopened-server receipt reconciliation, ActionResultV2 truth, terminal audit, and finding reconciliation floor without blind resend. Task 11 adds the desktop/browser tier-5 Product Trust consumer: bounded command/path/package-free Patrol evidence; exact empty parameter and elevated-versus-low-risk review; separate execution, evidence-sourced verification, and recovery cards; agent-attested confirmed updates; reboot fact without reboot authority; partial/unknown-health recovery; measured irreversible cleanup; and one durable receipt across reconnect, all exercised in current-build Chromium and a 390-pixel viewport. Malformed phases, counts, timestamps, usage, or cleanup arithmetic fail closed. The closed RG-01 through RG-12 matrix now supplies the disposable Debian/Ubuntu tier-6, Docker, browser, physical-device, live-Relay, revocation, cleanup, and independent Task 12 proof that previously kept claims 16 and 17 and both workflow scorecards operationally open. Task 10 remains sole owner of execution, verification, evidence, compensation, and rollback truth, and RG06/RG09 product outcomes remain agent-attested fix_verification_unknown. Proxmox VM/LXC lifecycle now supplies the first production distinct-trust-domain verification path: the node agent executes, the tenant-scoped server Proxmox client observes status and uptime, reboot requires an uptime reset, and ActionResultV2 keeps execution success independent from postcondition contradiction. The 2026-08-07 readiness slice moves live executor, emergency-stop, policy, expiry, resource-lock, contract-drift, and executor-owned availability checks to the human approval boundary; keeps rejection available; exposes exact remediation; and adds hash-bound replacement planning that reconstructs current Patrol policy and origin without copying approval. This follow-up now tracks only broader post-RC auditability beyond the proved bounded capability set, including additional operation and platform coverage, deeper compensation and rollback proof, and MSP or fleet aggregation; it does not reopen the accepted matrix. Raw model command, file write, arbitrary pod exec, legacy run_command, /api/ai/run-command, and enterprise command remediation remain retired with no replacement or executable historical authority.",
"owner": "project-owner",
"status": "planned",
"recorded_at": "2026-04-25",
@@ -2724,12 +2724,14 @@ in-flight projection.
The canonical Actions lifecycle may ask an executor-owned
`AvailabilityChecker` whether an already-planned capability is still reachable
immediately before human or automatic policy dispatch admission. Agent
before a human approval is persisted and again immediately before human or
automatic policy dispatch admission. Agent
connectivity and command-agent loss are read-only readiness evidence at this
boundary: an explicit unavailable result produces the stable
`action_execution_unavailable` refusal, a terminal failed action audit and
lifecycle event, and the normal action-completed publication without creating
a dispatch attempt or issuing an agent command. The check does not enroll,
boundary: at approval it prevents the decision record and returns the exact
bounded reconnect reason; at dispatch an explicit unavailable result produces
the stable `action_execution_unavailable` refusal, a terminal failed action
audit and lifecycle event, and the normal action-completed publication without
creating a dispatch attempt or issuing an agent command. The check does not enroll,
reconnect, reconfigure, update, or otherwise mutate an agent, and it cannot
replace canonical planning, approval, policy authorization, dispatch receipt,
or verification.
@@ -6584,6 +6584,16 @@ pending rather than converting a valid proposal into an investigation failure.
An idempotent resubmission returns the existing action disposition and must not
execute a terminal action twice.
Operator review reads a server-computed current-readiness projection, and the
shared lifecycle refuses a Patrol approval before persistence when the exact
plan has expired, drifted, lost executor reachability, encountered an operator
lock, or is stopped by current policy. An expired or drifted Patrol plan can be
refreshed only through the shared lifecycle: the replacement gets a new action
identity, retains trusted finding/investigation evidence links, and recomputes
current tenant/resource policy authorities under the fixed Patrol service
actor. The deciding human cannot supply or downgrade those broker-owned
fields, and no prior approval transfers to the replacement.
### Canonical mutation registry boundary
Assistant infrastructure mutations are classified by the generated
@@ -6597,10 +6607,11 @@ are omitted from offered schemas and denied if fabricated.
The registry audits enumerate actual registered tool discriminator values and
bind mechanically discovered API, job, and transport candidates to one registry
disposition. The action lifecycle API surface registers four lifecycle
disposition. The action lifecycle API surface registers five lifecycle
entries — `action.api.plan`, `action.api.decision`, `action.api.execute`, and
`action.api.force-fail` — all executed by `internal/actionlifecycle.Service`
with committed-lifecycle delivery. `action.api.force-fail` is the operator
`action.api.force-fail`, plus `action.api.refresh` — all executed by
`internal/actionlifecycle.Service` with committed-lifecycle delivery.
`action.api.force-fail` is the operator
override that terminalizes a dispatched action stranded without agent
completion evidence; it shares the `execute_action` capability and admin
approval floor with execute, writes terminal inconclusive audit truth through
@@ -8466,6 +8466,17 @@ are replan-required. MFA is not implemented by labels: until a server verifier
can validate and consume action-bound cryptographic evidence, MFA-required
decisions remain unavailable and no API or product surface may claim otherwise.
The human approval boundary consumes the same current, non-mutating readiness
gates as dispatch before it persists an approved decision. Action detail
includes a typed `readiness` projection with a stable code, bounded message,
operator remediation, check time, and refreshability; an exact executor-owned
reason remains visible instead of collapsing into a generic HTTP conflict.
Rejection stays available when readiness is lost. Expired or drifted immutable
plans may be replaced through `POST /api/actions/{id}/refresh`, bound to the
reviewed plan hash. The server creates a new action identity, preserves trusted
origin, and re-evaluates broker-owned policy factors; no prior approval carries
to the replacement.
Action completion now carries the unified-resource-owned `ActionResultV2`
through canonical completion events and agent resource context while retaining
legacy fields for one compatibility window. Lifecycle executors may return a
@@ -2208,10 +2208,15 @@ connection outcome, but they do not become storage or restore authority.
### Recovery actions retain execution-time readiness checks
Recovery-oriented capabilities admitted to canonical Actions are subject to
the same execution-time `AvailabilityChecker` gate as every other governed
action. Losing executor or agent reachability after planning or approval
produces a persisted `action_execution_unavailable` terminal no-effect refusal
before a dispatch attempt for both human and automatic policy execution.
the same current `AvailabilityChecker` gate as every other governed action.
Losing executor or agent reachability after planning prevents a human approval
from being recorded and exposes the bounded reconnect reason. If readiness is
lost after approval, execution produces a persisted
`action_execution_unavailable` terminal no-effect refusal before a dispatch
attempt for both human and automatic policy execution. Expired or drifted
plans refresh only through the shared action lifecycle into a distinct action
identity; recovery consumers cannot reuse the old approval or invent a
storage-local replacement path.
Recovery Assurance remains a separate deterministic domain: backup freshness,
protection posture, restore-chain evidence, and recoverability cannot satisfy
action authorization or live executor readiness, and the readiness refusal
@@ -1842,6 +1842,15 @@ compatibility, but a checker that names the capability and reports it
unavailable fails closed before either human or automatic policy admission.
This refusal cannot alter the approved plan, select a replacement executor, or
grant mutation authority from resource health alone.
The lifecycle also evaluates these gates before recording a human approval.
The immutable plan remains the reviewed snapshot, while the API projects
current readiness separately and prevents approvals that are already known to
fail before dispatch. Rejections are unaffected. Expired or drifted plans
refresh into a distinct action ID with a fresh resource version, policy
version, hash, expiry, and preflight; the replacement never inherits an
approval from the old action. Patrol replacements preserve their trusted
finding/investigation origin and recompute current tenant/resource authority
factors server-side.
`internal/unifiedresources/actions_test.go`,
`internal/actionlifecycle/service_test.go`, `internal/api/actions_test.go`,
`internal/api/contract_test.go`, and
+26 -38
View File
@@ -1,54 +1,42 @@
{
"version": 1,
"base_sha": "8fd43b307bf3faabc9c362d1ebccf1830be9ca0d",
"verified_at": "2026-08-07T15:30:51Z",
"result": "passed",
"base_sha": "1019310adf4aebaa88d5de0933be95dfc61284c8",
"verified_at": "2026-08-07T12:40:13Z",
"changed_paths": [
"frontend-modern/src/App.tsx",
"frontend-modern/src/components/BusinessEstateCard.tsx",
"frontend-modern/src/components/Settings/ProLicensePanel.tsx",
"frontend-modern/src/components/Settings/settingsNavCatalog.ts",
"frontend-modern/src/stores/sessionCapabilities.ts",
"frontend-modern/src/types/config.ts"
"frontend-modern/src/api/resourceActions.ts",
"frontend-modern/src/features/actions/ActionReviewDialog.tsx",
"frontend-modern/src/types/actionAudit.ts",
"frontend-modern/src/utils/actionAuditPresentation.ts"
],
"content_sha256": {
"frontend-modern/src/App.tsx": "7c22f48d04053556f07df34511b49847e9f9b2bd1217f819ff7d8b4f41ed83ef",
"frontend-modern/src/components/BusinessEstateCard.tsx": "af4463ea3a33de6a3a24b8ed89d41fcc42ceadb12736b6443a88556e18c88362",
"frontend-modern/src/components/Settings/ProLicensePanel.tsx": "c9d3a0405c626370239eaa3ff5fd1f7118887553ab8e80ef26b55008b817af3f",
"frontend-modern/src/components/Settings/settingsNavCatalog.ts": "b6a8f9c622c72a86dc7a0034c479e86fb9a1919ad0eb4a5fcd81222242fae865",
"frontend-modern/src/stores/sessionCapabilities.ts": "455c254df2ea7236bb9a1202e848f7226b963e1e36c761799d6eeca70a845b19",
"frontend-modern/src/types/config.ts": "36f050dba43af75781f98435faaa9921072d696d6133fb7f10bdfe04506c39bd"
"frontend-modern/src/api/resourceActions.ts": "0c4bac0a4f779f6afeee39f45ad46d51baf0b1b5a47ebdc3f424148804f1c329",
"frontend-modern/src/features/actions/ActionReviewDialog.tsx": "992b1203fb775cf2e1eb86ffc04f1ce5ca5e072f26dedfbb0f9338459022c2eb",
"frontend-modern/src/types/actionAudit.ts": "e44b0cf97b070c63da667fa035a2a5f427ccc58936b7a4296e520eec1f9a1ed0",
"frontend-modern/src/utils/actionAuditPresentation.ts": "fd1ae3e44b9881d7324845520cfdeaf958dcb30bf62a59f6ec69d342ca13290a"
},
"routes": [
"/",
"/settings/pulse-intelligence/billing/plan",
"/settings/security-roles"
],
"states": [
"free Community tier with hideUpgrade=false served by the rebuilt dev backend (verified via /api/security/status)",
"sessionCapabilities.businessEstate=true from 6 mock PVE nodes on the free tier",
"business-estate card eligible state (star prompt dismissed, first-seen recorded on a prior day), re-exercised after the storage keys moved local to the component",
"business-estate card permanently dismissed state after each dismissal action",
"settings navigation for a free install showing Data & Reports, Roles, Users, Audit Log, Audit Webhooks, Remote Access, and Plans & Billing",
"Roles panel feature gate with visible View plans CTA on the free tier",
"Plans & Billing Community plan with the MSPs and multi-client providers section"
],
"interactions": [
"logged in as admin via real clicks and typing at 1280x800",
"clicked 'See business plans' on the business-estate card: navigated to /settings/pulse-intelligence/billing/plan and set the permanent dismissal key",
"reset dismissal, reloaded, clicked 'This is a homelab': card removed from DOM and dismissal persisted (repeated against the final component bytes)",
"navigated to Roles via settings navigation and confirmed the inline gate with View plans CTA",
"read the Plans & Billing page including the MSP provider section and pulserelay.pro/msp link",
"repeated the card render and homelab dismissal at mobile viewport with the card clearing the bottom navigation bar"
"/actions?action=action-browser-1"
],
"viewports": [
{
"width": 1280,
"height": 800
"width": 1440,
"height": 1000
},
{
"width": 375,
"height": 812
"width": 390,
"height": 844
}
],
"states": [
"Pending action whose executor reports an exact command-agent readiness refusal",
"Drifted pending action with a refreshable replacement plan",
"Fresh replacement action ready for a new operator approval"
],
"interactions": [
"Confirmed the exact executor-owned refusal and remediation are visible on desktop, Reject remains available, and Approve is absent",
"Confirmed the mobile dialog stays within the 390px viewport with no document overflow and keeps Close, Refresh plan, and Reject reachable",
"Refreshed the drifted plan through the real dialog and API client, confirmed the success notice, removed Refresh plan, and exposed Approve only for the fresh replacement",
"Reset the viewport override and finalized the browser session"
]
}
@@ -18,4 +18,13 @@ describe('ResourceActionsAPI durable inbox', () => {
await ResourceActionsAPI.getAction('action/one');
expect(fetchJSON).toHaveBeenCalledWith('/api/actions/action%2Fone');
});
it('refreshes the exact reviewed action plan', async () => {
fetchJSON.mockResolvedValue({ audit: {}, events: [] });
await ResourceActionsAPI.refreshAction('action/one', ' sha256:reviewed ');
expect(fetchJSON).toHaveBeenCalledWith('/api/actions/action%2Fone/refresh', {
method: 'POST',
body: JSON.stringify({ planHash: 'sha256:reviewed' }),
});
});
});
@@ -32,6 +32,16 @@ export class ResourceActionsAPI {
return apiFetchJSON<ActionDetailResponse>(`/api/actions/${encodeURIComponent(actionId)}`);
}
static async refreshAction(actionId: string, planHash: string): Promise<ActionDetailResponse> {
return apiFetchJSON<ActionDetailResponse>(
`/api/actions/${encodeURIComponent(actionId)}/refresh`,
{
method: 'POST',
body: JSON.stringify({ planHash: reviewedPlanHash(planHash) }),
},
);
}
static async listPendingActions(): Promise<PendingActionsResponse> {
return apiFetchJSON<PendingActionsResponse>('/api/actions/pending');
}
@@ -37,6 +37,7 @@ export const ActionReviewDialog: Component<{
const hasCurrentPolicyProvenance = createMemo(
() => audit()?.plan.policyDecision?.status === 'resolved',
);
const readiness = createMemo(() => props.detail?.readiness);
const reviewedPlanHash = createMemo(() => audit()?.plan.planHash?.trim() || '');
const aptParametersValid = createMemo(() => {
const action = audit();
@@ -48,13 +49,14 @@ export const ActionReviewDialog: Component<{
const timestamp = new Date(expiresAt).valueOf();
return Number.isNaN(timestamp) || timestamp <= clock();
});
const canDecide = () =>
const canReject = () =>
!readOnly() &&
reviewedPlanHash() &&
hasCurrentPolicyProvenance() &&
aptParametersValid() &&
!isExpired() &&
audit()?.state === 'pending_approval';
const canApprove = () => canReject() && readiness()?.ready === true;
// Low-risk capabilities (rollback-supported, routine) collapse the decision
// to one confirmation: a single click records the approval and dispatches
// execution. Both lifecycle records are still written server-side.
@@ -65,11 +67,16 @@ export const ActionReviewDialog: Component<{
hasCurrentPolicyProvenance() &&
aptParametersValid() &&
!isExpired() &&
readiness()?.ready === true &&
(audit()?.state === 'approved' ||
(audit()?.state === 'planned' && !audit()?.plan.requiresApproval));
const invalidActionMessage = createMemo(() => {
const state = audit()?.state;
const actionable = state === 'pending_approval' || state === 'approved' || state === 'planned';
const actionable =
state === 'pending_approval' ||
state === 'approved' ||
state === 'planned' ||
state === 'expired';
if (!actionable) return '';
if (readOnly())
return 'This session is read-only. You can inspect the action and its policy evidence, but you cannot approve or run it.';
@@ -79,11 +86,29 @@ export const ActionReviewDialog: Component<{
return 'This action has no current server policy provenance. Close it and create a new plan before approving or running anything.';
if (!aptParametersValid())
return 'This host-maintenance action contains unexpected operator-selected parameters. Close it and create a new plan; do not approve or run this record.';
if (readiness() && !readiness()!.ready) {
return [readiness()!.message, readiness()!.remediation].filter(Boolean).join(' ');
}
if (isExpired())
return 'This action review expired. Close it and create a new plan so current resource and policy state can be checked again.';
return 'This action review expired. Refresh the plan so current resource and policy state can be checked again.';
return '';
});
const canRefreshPlan = createMemo(
() =>
!readOnly() &&
Boolean(reviewedPlanHash()) &&
(readiness()?.refreshable === true || isExpired()) &&
['planned', 'pending_approval', 'approved', 'expired'].includes(audit()?.state ?? ''),
);
const actionableErrorMessage = (cause: unknown, fallback: string): string => {
if (!(cause instanceof Error)) return fallback;
const details = (cause as Error & { details?: Record<string, string> }).details;
const reason = details?.reason?.trim();
return reason || cause.message || fallback;
};
const refresh = async () => {
const actionId = audit()?.id;
if (!actionId) return;
@@ -111,9 +136,28 @@ export const ActionReviewDialog: Component<{
);
if (outcome === 'rejected') props.onClose();
} catch (cause) {
const message =
cause instanceof Error ? cause.message : 'The decision could not be recorded.';
setError(message);
setError(actionableErrorMessage(cause, 'The decision could not be recorded.'));
} finally {
setBusy(false);
}
};
const refreshPlan = async () => {
const action = audit();
if (!action || busy()) return;
setBusy(true);
setError('');
try {
const replacement = await ResourceActionsAPI.refreshAction(action.id, reviewedPlanHash());
await props.onChanged?.(replacement);
notificationStore.success('Plan refreshed. Review the replacement before approving it.');
} catch (cause) {
setError(actionableErrorMessage(cause, 'The action plan could not be refreshed.'));
try {
await refresh();
} catch {
/* preserve the refresh failure */
}
} finally {
setBusy(false);
}
@@ -141,9 +185,7 @@ export const ActionReviewDialog: Component<{
'Action approved and dispatched. Review the recorded outcome below.',
);
} catch (cause) {
const message =
cause instanceof Error ? cause.message : 'The action could not be approved and run.';
setError(message);
setError(actionableErrorMessage(cause, 'The action could not be approved and run.'));
try {
await refresh();
} catch {
@@ -170,8 +212,7 @@ export const ActionReviewDialog: Component<{
'Action dispatch response recorded. Review execution, verification, and recovery separately.',
);
} catch (cause) {
const message = cause instanceof Error ? cause.message : 'The action could not be run.';
setError(message);
setError(actionableErrorMessage(cause, 'The action could not be run.'));
try {
await refresh();
} catch {
@@ -236,10 +277,17 @@ export const ActionReviewDialog: Component<{
</div>
<footer class="flex flex-col-reverse gap-2 border-t border-border px-5 py-4 sm:flex-row sm:justify-end">
<Button onClick={props.onClose}>Close</Button>
<Show when={canDecide()}>
<Show when={canRefreshPlan()}>
<Button variant="primary" isLoading={busy()} onClick={() => void refreshPlan()}>
Refresh plan
</Button>
</Show>
<Show when={canReject()}>
<Button variant="danger" disabled={busy()} onClick={() => void decide('rejected')}>
Reject
</Button>
</Show>
<Show when={canApprove()}>
<Show
when={singleConfirmation()}
fallback={
@@ -292,6 +292,13 @@ describe('ActionDecisionPacket', () => {
transportRequestId: 'transport-1',
receivedAt: '2026-07-12T00:05:00Z',
},
readiness: {
ready: false,
code: 'action_not_actionable',
message: 'This action is no longer open for approval or dispatch.',
refreshable: false,
checkedAt: aptAudit.updatedAt,
},
}}
/>
));
@@ -6,7 +6,12 @@ import type { ActionAuditRecord, ActionDetailResponse } from '@/types/actionAudi
import { ActionReviewDialog } from '../ActionReviewDialog';
vi.mock('@/api/resourceActions', () => ({
ResourceActionsAPI: { getAction: vi.fn(), decideAction: vi.fn(), executeAction: vi.fn() },
ResourceActionsAPI: {
getAction: vi.fn(),
refreshAction: vi.fn(),
decideAction: vi.fn(),
executeAction: vi.fn(),
},
}));
vi.mock('@/stores/notifications', () => ({
notificationStore: { success: vi.fn(), error: vi.fn(), warning: vi.fn() },
@@ -96,7 +101,17 @@ const makeAudit = (
},
verificationOutcome: { status: 'unknown' },
});
const detail = (audit: ActionAuditRecord): ActionDetailResponse => ({ audit, events: [] });
const detail = (audit: ActionAuditRecord): ActionDetailResponse => ({
audit,
events: [],
readiness: {
ready: true,
code: 'ready',
message: 'Action is ready for approval and dispatch.',
refreshable: false,
checkedAt: '2026-07-12T00:00:00Z',
},
});
describe('ActionReviewDialog trust gates', () => {
it('offers no approve or run control for legacy provenance', () => {
@@ -144,6 +159,54 @@ describe('ActionReviewDialog trust gates', () => {
expect(screen.queryByRole('button', { name: 'Run action' })).toBeNull();
});
it('blocks approval with the exact live-readiness unblock while keeping rejection available', () => {
const current = detail(makeAudit('resolved', '2099-01-01T00:00:00Z'));
current.readiness = {
ready: false,
code: 'command_agent_disconnected',
message: 'Connect the command agent for web-42.',
remediation: 'Reconnect the agent, then refresh this check.',
refreshable: false,
checkedAt: '2026-07-12T00:00:00Z',
};
render(() => <ActionReviewDialog detail={current} onClose={vi.fn()} />);
expect(screen.getByTestId('action-review-invalid')).toHaveTextContent(
'Connect the command agent for web-42. Reconnect the agent, then refresh this check.',
);
expect(screen.queryByRole('button', { name: 'Approve' })).toBeNull();
expect(screen.getByRole('button', { name: 'Reject' })).toBeInTheDocument();
});
it('refreshes a drifted plan and hands the replacement back for review', async () => {
const current = detail(makeAudit('resolved', '2099-01-01T00:00:00Z'));
current.readiness = {
ready: false,
code: 'action_plan_drift',
message: 'The resource changed after this plan was created.',
remediation: 'Refresh the plan and review the replacement.',
refreshable: true,
checkedAt: '2026-07-12T00:00:00Z',
};
const replacementAudit = { ...current.audit, id: 'action-2' };
replacementAudit.plan = {
...current.audit.plan,
actionId: 'action-2',
planHash: 'sha256:replacement',
};
const replacement = detail(replacementAudit);
vi.mocked(ResourceActionsAPI.refreshAction).mockResolvedValue(replacement);
const onChanged = vi.fn();
render(() => <ActionReviewDialog detail={current} onClose={vi.fn()} onChanged={onChanged} />);
fireEvent.click(screen.getByRole('button', { name: 'Refresh plan' }));
await waitFor(() => {
expect(ResourceActionsAPI.refreshAction).toHaveBeenCalledWith(
'action-1',
'sha256:reviewed-plan',
);
expect(onChanged).toHaveBeenCalledWith(replacement);
});
});
it('keeps mock and other read-only sessions inspectable without mutation controls', () => {
syncSessionPresentationPolicy({
presentationPolicy: {
+11
View File
@@ -244,6 +244,7 @@ export type ActionAuditRefusalPrefix =
| 'plan_drift:'
| 'action_plan_expired:'
| 'action_dry_run_only:'
| 'action_execution_unavailable:'
| 'resource_remediation_locked:'
| 'policy_authorization_expired:'
| 'policy_authorization_invalid:'
@@ -346,9 +347,19 @@ export interface ActionDetailResponse {
events: ActionLifecycleEvent[];
attempt?: ActionDispatchAttempt;
receipt?: ActionDispatchReceipt;
readiness: ActionReadiness;
readOnly?: boolean;
}
export interface ActionReadiness {
ready: boolean;
code: string;
message: string;
remediation?: string;
refreshable: boolean;
checkedAt: string;
}
export interface ActionDecisionResponse {
actionId: string;
state: ActionAuditState;
@@ -107,6 +107,13 @@ const ACTION_REFUSAL_PRESENTATION: Record<
className:
'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300',
},
'action_execution_unavailable:': {
label: 'Target not ready',
detail:
'Pulse refused the action before dispatch because the target or execution path was not ready.',
className:
'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-300',
},
'resource_remediation_locked:': {
label: 'Resource remediation locked',
detail:
+218 -45
View File
@@ -46,6 +46,12 @@ type AvailabilityChecker interface {
CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness
}
// RefreshPlanner reconstructs broker-owned planning inputs for a replacement
// plan. Public/operator actions use the lifecycle default; first-party
// brokers use this hook to re-evaluate current policy factors without letting
// the HTTP caller forge origin or authority metadata.
type RefreshPlanner func(ctx context.Context, orgID string, previous unified.ActionAuditRecord, actor unified.ActionActor, requestID string) (unified.ActionRequest, PlanOptions, error)
// Store is the narrow persistence surface the lifecycle needs. It is a
// structural subset of unified.ResourceStore so the canonical store
// satisfies it without adaptation.
@@ -86,6 +92,7 @@ type Service struct {
DecisionAuthorizer DecisionAuthorizer
ExecutionAuthorizer ExecutionAuthorizer
StepUpVerifier StepUpVerifier
RefreshPlanner RefreshPlanner
// OnActionCompleted receives every terminal (completed/failed) audit
// record, including refused-before-dispatch failures, so SSE bridges
// and reconcilers observe the full lifecycle regardless of transport.
@@ -112,6 +119,19 @@ type ActionDetail struct {
Receipt *unified.ActionDispatchReceipt `json:"receipt,omitempty"`
}
// ActionReadiness is the current, server-computed admission posture for an
// action. It is deliberately separate from the immutable plan preflight: the
// plan records what was true when it was created, while readiness reports
// whether the same action can safely cross approval and dispatch now.
type ActionReadiness struct {
Ready bool `json:"ready"`
Code string `json:"code"`
Message string `json:"message"`
Remediation string `json:"remediation,omitempty"`
Refreshable bool `json:"refreshable"`
CheckedAt time.Time `json:"checkedAt"`
}
type ActionListView string
const (
@@ -212,6 +232,7 @@ var (
ErrApprovalActorNotHuman = errors.New("detached or service actors cannot satisfy human approval")
ErrApprovalSeparationRequired = errors.New("requester cannot approve this action")
ErrDecisionReplayConflict = errors.New("action decision replay conflicts with the persisted decision")
ErrActionRefreshNotAllowed = errors.New("action plan is current or no longer refreshable")
)
// ResourceNotFoundError reports that the requested resource is not present
@@ -469,6 +490,61 @@ func (s *Service) PlanWithOptions(ctx context.Context, orgID string, req unified
return plan, nil
}
// Refresh creates a fresh immutable plan for an expired or drifted action.
// The previous record remains historical (and an unexpired drifted record
// naturally expires at its original short TTL); callers always receive the
// replacement action ID and must review that replacement before deciding.
func (s *Service) Refresh(ctx context.Context, orgID, actionID string, actor unified.ActionActor) (unified.ActionAuditRecord, error) {
previous, found, err := s.Get(orgID, actionID)
if err != nil {
return unified.ActionAuditRecord{}, err
}
if !found {
return unified.ActionAuditRecord{}, &ActionNotFoundError{ActionID: strings.TrimSpace(actionID)}
}
refreshable := previous.State == unified.ActionStateExpired || previous.Plan.ExpiresAt.IsZero() || !s.now().Before(previous.Plan.ExpiresAt)
if !refreshable {
freshnessErr := s.ValidatePlanFresh(orgID, previous)
switch {
case freshnessErr == nil:
case errors.Is(freshnessErr, unified.ErrActionPlanDrift):
refreshable = true
default:
return unified.ActionAuditRecord{}, &FreshnessCheckError{Err: freshnessErr}
}
}
if !refreshable {
return unified.ActionAuditRecord{}, ErrActionRefreshNotAllowed
}
requestID := "refresh:" + previous.ID
req := previous.Request
req.RequestID = requestID
req.RequestedBy = ""
req.Actor = unified.ActionActor{}
opts := PlanOptions{Actor: actor, Origin: previous.Origin}
if s.RefreshPlanner != nil {
req, opts, err = s.RefreshPlanner(ctx, strings.TrimSpace(orgID), previous, actor, requestID)
if err != nil {
return unified.ActionAuditRecord{}, err
}
}
plan, err := s.PlanWithOptions(ctx, orgID, req, opts)
if err != nil {
return unified.ActionAuditRecord{}, err
}
if plan.ActionID == previous.ID {
return unified.ActionAuditRecord{}, fmt.Errorf("%w: replacement action identity did not change", ErrActionRefreshNotAllowed)
}
replacement, found, err := s.Get(orgID, plan.ActionID)
if err != nil {
return unified.ActionAuditRecord{}, err
}
if !found {
return unified.ActionAuditRecord{}, &QueryError{Op: "replacement action audit", Err: errors.New("persisted replacement is unavailable")}
}
return replacement, nil
}
// Get returns the authoritative audit record for an action.
func (s *Service) Get(orgID, actionID string) (unified.ActionAuditRecord, bool, error) {
store, err := s.store(orgID)
@@ -686,12 +762,24 @@ func (s *Service) Decide(ctx context.Context, orgID, actionID string, decision u
if err := validateDecisionBinding(record, orgID, decision); err != nil {
return unified.ActionAuditRecord{}, err
}
if record.State != unified.ActionStatePending {
return unified.ActionAuditRecord{}, unified.ErrActionNotPending
}
if s.DecisionAuthorizer == nil {
return unified.ActionAuditRecord{}, ErrDecisionAuthorizationUnavailable
}
if err := s.DecisionAuthorizer.AuthorizeDecision(ctx, orgID, record, decision); err != nil {
return unified.ActionAuditRecord{}, err
}
// Rejections must always remain possible, but an approval is only useful
// when the exact reviewed plan can still pass the same live gates used at
// dispatch. Checking here (inside the lifecycle boundary) prevents an
// approval from being persisted during a resource/policy/readiness race.
if decision.Outcome == unified.OutcomeApproved {
if err := s.ValidateCurrentReadiness(ctx, orgID, record); err != nil {
return unified.ActionAuditRecord{}, err
}
}
if err := s.validateApprovalFloor(ctx, record, decision, true); err != nil {
return unified.ActionAuditRecord{}, err
}
@@ -703,10 +791,6 @@ func (s *Service) Decide(ctx context.Context, orgID, actionID string, decision u
Reason: decision.Reason,
Evidence: &decision.Evidence,
}
if record.State != unified.ActionStatePending {
return unified.ActionAuditRecord{}, unified.ErrActionNotPending
}
now := s.now()
if approval.Timestamp.IsZero() {
approval.Timestamp = now
@@ -742,6 +826,11 @@ func (s *Service) Decide(ctx context.Context, orgID, actionID string, decision u
if err := s.DecisionAuthorizer.AuthorizeDecision(ctx, orgID, current, decision); err != nil {
return unified.ActionAuditRecord{}, err
}
if decision.Outcome == unified.OutcomeApproved {
if err := s.ValidateCurrentReadiness(ctx, orgID, current); err != nil {
return unified.ActionAuditRecord{}, err
}
}
if err := s.validateApprovalFloor(ctx, current, decision, false); err != nil {
return unified.ActionAuditRecord{}, err
}
@@ -767,6 +856,125 @@ func (s *Service) Decide(ctx context.Context, orgID, actionID string, decision u
return unified.ActionAuditRecord{}, &PersistError{Op: "action decision", Err: unified.ErrActionDecisionRevisionConflict}
}
// ValidateCurrentReadiness applies every non-mutating gate that must remain
// true from approval through dispatch. It intentionally does not validate the
// approval state itself, so pending actions can be checked before a human
// decision and approved actions can be checked again before execution.
func (s *Service) ValidateCurrentReadiness(ctx context.Context, orgID string, record unified.ActionAuditRecord) error {
now := s.now()
switch record.State {
case unified.ActionStatePlanned, unified.ActionStatePending, unified.ActionStateApproved:
case unified.ActionStateExpired:
return unified.ErrActionPlanExpired
default:
return unified.ErrActionExecutionFinal
}
if record.Plan.ExpiresAt.IsZero() || !now.Before(record.Plan.ExpiresAt) || record.State == unified.ActionStateExpired {
return unified.ErrActionPlanExpired
}
if unified.NormalizeApprovalRequirement(record.Plan.ApprovalRequirement, record.Plan.ApprovalPolicy).Floor == unified.ApprovalDryRun {
return unified.ErrActionDryRunOnly
}
if stopped, err := s.emergencyStopped(orgID); err != nil {
return &PolicyCheckError{Err: err}
} else if stopped {
return unified.ErrActionEmergencyStop
}
if s.Executor == nil {
return ErrExecutorUnavailable
}
if err := s.ValidatePlanFresh(orgID, record); err != nil {
if errors.Is(err, unified.ErrActionPlanDrift) {
return err
}
return &FreshnessCheckError{Err: err}
}
store, err := s.store(orgID)
if err != nil {
return err
}
if err := validateExecutionPolicy(store, record); err != nil {
if errors.Is(err, unified.ErrResourceRemediationLocked) {
return err
}
return &PolicyCheckError{Err: err}
}
if err := s.ValidateExecutionAvailable(ctx, orgID, record); err != nil {
var unavailable *AvailabilityRefusedError
if errors.As(err, &unavailable) {
return err
}
return &AvailabilityCheckError{Err: err}
}
return nil
}
// AssessCurrentReadiness converts the canonical readiness gates into a stable
// UI/API projection while retaining exact executor-owned refusal detail.
func (s *Service) AssessCurrentReadiness(ctx context.Context, orgID string, record unified.ActionAuditRecord) ActionReadiness {
checkedAt := s.now()
err := s.ValidateCurrentReadiness(ctx, orgID, record)
readiness := ActionReadiness{Ready: err == nil, Code: "ready", Message: "Action is ready for approval and dispatch.", CheckedAt: checkedAt}
if err == nil {
return readiness
}
readiness.Ready = false
readiness.Code = "readiness_check_failed"
readiness.Message = "Pulse could not confirm current action readiness."
readiness.Remediation = "Retry the readiness check before approving or running this action."
switch {
case errors.Is(err, unified.ErrActionPlanExpired):
readiness.Code = "action_plan_expired"
readiness.Message = "This action plan has expired."
readiness.Remediation = "Refresh the plan to re-check the current resource and policy state."
readiness.Refreshable = true
case errors.Is(err, unified.ErrActionPlanDrift):
readiness.Code = "action_plan_drift"
readiness.Message = "The resource or capability contract changed after this plan was created."
readiness.Remediation = "Refresh the plan and review the replacement before approving it."
readiness.Refreshable = true
case errors.Is(err, unified.ErrActionDryRunOnly):
readiness.Code = "action_dry_run_only"
readiness.Message = "This plan is dry-run only."
readiness.Remediation = "Create a plan for a capability that permits execution."
case errors.Is(err, unified.ErrActionEmergencyStop):
readiness.Code = "action_emergency_stop"
readiness.Message = "Action dispatch is stopped by the operator."
readiness.Remediation = "Turn off the Patrol emergency stop, then refresh readiness."
case errors.Is(err, unified.ErrResourceRemediationLocked):
readiness.Code = "resource_remediation_locked"
readiness.Message = "This resource is locked against remediation."
readiness.Remediation = "Remove the resource remediation lock, then refresh readiness."
case errors.Is(err, ErrExecutorUnavailable):
readiness.Code = "action_executor_unavailable"
readiness.Message = "No action executor is available."
readiness.Remediation = "Restore the action execution service before approving this action."
case errors.Is(err, unified.ErrActionExecutionUnavailable):
readiness.Code = "action_execution_unavailable"
readiness.Message = "Action execution is currently unavailable."
readiness.Remediation = "Restore target readiness, then refresh this check."
var unavailable *AvailabilityRefusedError
if errors.As(err, &unavailable) {
readiness.Code = firstNonEmptyString(unavailable.Readiness.ReasonCode, readiness.Code)
readiness.Message = firstNonEmptyString(unavailable.Readiness.Reason, readiness.Message)
}
case errors.Is(err, unified.ErrActionExecutionFinal):
readiness.Code = "action_not_actionable"
readiness.Message = "This action is no longer open for approval or dispatch."
readiness.Remediation = "Review the recorded outcome."
}
return readiness
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func decisionReplay(record unified.ActionAuditRecord, decision unified.ActionDecision) (exact, conflict bool) {
for _, approval := range record.Approvals {
actor := unified.NormalizeActionActor(approval.ActorBinding)
@@ -911,18 +1119,6 @@ func (s *Service) Execute(ctx context.Context, orgID, actionID string, actor uni
if record.State == unified.ActionStateExpired {
return record, unified.ErrActionPlanExpired
}
if stopped, stopErr := s.emergencyStopped(orgID); stopErr != nil || stopped {
if stopErr != nil {
return unified.ActionAuditRecord{}, &PolicyCheckError{Err: stopErr}
}
failed, persistErr := RecordRefusedExecution(store, record, actorID, now, unified.ErrActionEmergencyStop)
if persistErr != nil {
return unified.ActionAuditRecord{}, &PersistError{Op: "emergency-stop refusal", Err: persistErr}
}
s.publishTransition(orgID, failed)
s.publishCompleted(failed)
return failed, unified.ErrActionEmergencyStop
}
if err := unified.ValidateActionExecutionStart(record, now); err != nil {
if unified.IsPermanentActionExecutionRefusal(err) {
failed, persistErr := RecordRefusedExecution(store, record, actorID, now, err)
@@ -935,22 +1131,11 @@ func (s *Service) Execute(ctx context.Context, orgID, actionID string, actor uni
}
return unified.ActionAuditRecord{}, err
}
if s.Executor == nil {
return unified.ActionAuditRecord{}, ErrExecutorUnavailable
}
if err := s.ValidatePlanFresh(orgID, record); err != nil {
if errors.Is(err, unified.ErrActionPlanDrift) {
failed, persistErr := RecordRefusedExecution(store, record, actorID, now, err)
if persistErr != nil {
return unified.ActionAuditRecord{}, &PersistError{Op: "refused action execution", Err: persistErr}
}
s.publishTransition(orgID, failed)
s.publishCompleted(failed)
return failed, err
}
return unified.ActionAuditRecord{}, &FreshnessCheckError{Err: err}
}
if err := validateExecutionPolicy(store, record); err != nil {
// Reuse the exact gate set applied before approval. This second check is
// the dispatch-side half of the boundary: it closes the race between the
// operator's decision and durable dispatch admission without maintaining a
// second, subtly different readiness implementation.
if err := s.ValidateCurrentReadiness(ctx, orgID, record); err != nil {
if unified.IsPermanentActionExecutionRefusal(err) {
failed, persistErr := RecordRefusedExecution(store, record, actorID, now, err)
if persistErr != nil {
@@ -960,19 +1145,7 @@ func (s *Service) Execute(ctx context.Context, orgID, actionID string, actor uni
s.publishCompleted(failed)
return failed, err
}
return unified.ActionAuditRecord{}, &PolicyCheckError{Err: err}
}
if err := s.ValidateExecutionAvailable(ctx, orgID, record); err != nil {
if unified.IsPermanentActionExecutionRefusal(err) {
failed, persistErr := RecordRefusedExecution(store, record, actorID, now, err)
if persistErr != nil {
return unified.ActionAuditRecord{}, &PersistError{Op: "unavailable action execution refusal", Err: persistErr}
}
s.publishTransition(orgID, failed)
s.publishCompleted(failed)
return failed, err
}
return unified.ActionAuditRecord{}, &AvailabilityCheckError{Err: err}
return unified.ActionAuditRecord{}, err
}
started, startEvent, err := unified.BeginActionExecution(record, actorID, now)
+84 -4
View File
@@ -779,11 +779,11 @@ func runEmergencyStopBlocksHumanAndPolicy(t *testing.T, store unified.ResourceSt
if err != nil {
t.Fatal(err)
}
if _, err = service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, service, "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved})); err != nil {
t.Fatal(err)
if _, err = service.Decide(context.Background(), "default", plan.ActionID, testActionDecision(t, service, "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Method: unified.MethodAPI, Outcome: unified.OutcomeApproved})); !errors.Is(err, unified.ErrActionEmergencyStop) {
t.Fatalf("human approval error=%v", err)
}
if _, err = service.Execute(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"), ""); !errors.Is(err, unified.ErrActionEmergencyStop) {
t.Fatalf("human error=%v", err)
if record, found, getErr := service.Get("default", plan.ActionID); getErr != nil || !found || record.State != unified.ActionStatePending {
t.Fatalf("blocked approval must remain pending: found=%v state=%s err=%v", found, record.State, getErr)
}
policy := restartRequest()
policy.RequestID = "policy-stop"
@@ -1213,6 +1213,86 @@ func TestDecideApprovesPendingAction(t *testing.T) {
}
}
func TestApprovalRequiresCurrentReadinessButRejectionRemainsAvailable(t *testing.T) {
now := time.Now().UTC()
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
plan, err := env.service.Plan(context.Background(), "default", restartRequest(), testActionActor("requester", "default"))
if err != nil {
t.Fatal(err)
}
env.executor.readiness = &unified.ResourceActionReadiness{
Name: "restart",
Available: false,
ReasonCode: "agent_disconnected",
Reason: "Connect the command agent for web-42.",
}
readiness := env.service.AssessCurrentReadiness(context.Background(), "default", mustActionRecord(t, env.service, plan.ActionID))
if readiness.Ready || readiness.Code != "agent_disconnected" || readiness.Message != "Connect the command agent for web-42." {
t.Fatalf("readiness=%#v", readiness)
}
decision := testActionDecision(t, env.service, "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Outcome: unified.OutcomeApproved})
if _, err := env.service.Decide(context.Background(), "default", plan.ActionID, decision); !errors.Is(err, unified.ErrActionExecutionUnavailable) {
t.Fatalf("approval error=%v", err)
}
current := mustActionRecord(t, env.service, plan.ActionID)
if current.State != unified.ActionStatePending || len(current.Approvals) != 0 {
t.Fatalf("refused approval mutated record: %#v", current)
}
rejection := testActionDecision(t, env.service, "default", plan.ActionID, unified.ActionApprovalRecord{Actor: "operator", Outcome: unified.OutcomeRejected})
rejected, err := env.service.Decide(context.Background(), "default", plan.ActionID, rejection)
if err != nil || rejected.State != unified.ActionStateRejected {
t.Fatalf("rejected=%#v err=%v", rejected, err)
}
}
func TestRefreshReplacesExpiredPlanAndPreservesTrustedOrigin(t *testing.T) {
now := time.Now().UTC()
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
origin := &unified.ActionOrigin{Surface: "operational_trust_attention", OperationalRecordID: "attention-1", EvidenceIDs: []string{"evidence-1"}}
plan, err := env.service.PlanWithOptions(context.Background(), "default", restartRequest(), PlanOptions{Actor: testActionActor("requester", "default"), Origin: origin})
if err != nil {
t.Fatal(err)
}
readinessChecks := 0
env.service.Now = func() time.Time {
readinessChecks++
if readinessChecks == 1 {
return plan.ExpiresAt.Add(time.Second)
}
return time.Now().UTC()
}
replacement, err := env.service.Refresh(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"))
if err != nil {
t.Fatal(err)
}
if replacement.ID == plan.ActionID || replacement.Request.RequestID != "refresh:"+plan.ActionID {
t.Fatalf("replacement=%#v", replacement)
}
if replacement.Origin == nil || replacement.Origin.Surface != origin.Surface || replacement.Origin.OperationalRecordID != origin.OperationalRecordID {
t.Fatalf("origin=%#v", replacement.Origin)
}
previous := mustActionRecord(t, env.service, plan.ActionID)
if previous.State != unified.ActionStateExpired {
t.Fatalf("previous state=%q", previous.State)
}
replayed, err := env.service.Refresh(context.Background(), "default", plan.ActionID, testActionActor("operator", "default"))
if err != nil || replayed.ID != replacement.ID {
t.Fatalf("replayed=%#v err=%v", replayed, err)
}
}
func mustActionRecord(t *testing.T, service *Service, actionID string) unified.ActionAuditRecord {
t.Helper()
record, found, err := service.Get("default", actionID)
if err != nil || !found {
t.Fatalf("Get(%q): found=%v err=%v", actionID, found, err)
}
return record
}
func TestExecuteRunsApprovedActionToTerminalAudit(t *testing.T) {
now := time.Now().UTC()
env := newServiceEnv(t, testResource(now, unified.ApprovalAdmin))
+2
View File
@@ -37,6 +37,8 @@ const (
AgentErrCodeActionReadinessCheckFailed = "action_execution_availability_failed"
AgentErrCodeActionPlanDrift = "action_plan_drift"
AgentErrCodeActionPlanIdentityMismatch = "action_plan_identity_mismatch"
AgentErrCodeActionRefreshNotAllowed = "action_refresh_not_allowed"
AgentErrCodeActionEmergencyStop = "action_emergency_stop"
AgentErrCodeResourceRemediationLocked = "resource_remediation_locked"
AgentErrCodeMockModeEnabled = "mock_mode_enabled"
AgentErrCodeActionExecutorUnavailable = "action_executor_unavailable"
+10 -1
View File
@@ -612,6 +612,7 @@ var (
AgentErrCodeResourceNotFound,
AgentErrCodeCapabilityNotFound,
AgentErrCodeActionExecutionUnavailable,
AgentErrCodeActionRefreshNotAllowed,
}
agentCapabilityDecisionActionErrorCodes = []string{
AgentErrCodeMockModeEnabled,
@@ -628,6 +629,13 @@ var (
AgentErrCodeActionDecisionConflict,
AgentErrCodeActionSeparationRequired,
AgentErrCodeActionReplanRequired,
AgentErrCodeActionExecutionUnavailable,
AgentErrCodeActionPlanDrift,
AgentErrCodeActionEmergencyStop,
AgentErrCodeActionDryRunOnly,
AgentErrCodeResourceRemediationLocked,
AgentErrCodeActionExecutorUnavailable,
AgentErrCodeActionReadinessCheckFailed,
}
agentCapabilityExecuteActionErrorCodes = []string{
AgentErrCodeMockModeEnabled,
@@ -642,6 +650,7 @@ var (
AgentErrCodeActionPlanExpired,
AgentErrCodeActionExecutionUnavailable,
AgentErrCodeActionPlanDrift,
AgentErrCodeActionEmergencyStop,
AgentErrCodeActionPlanIdentityMismatch,
AgentErrCodeResourceRemediationLocked,
AgentErrCodeActionExecutorUnavailable,
@@ -1054,7 +1063,7 @@ var canonicalManifest = Manifest{
{
Name: DecideActionCapabilityName,
Title: "Decide action",
Description: "Record an approval decision (approved or rejected) on a previously planned action. The actor is taken from the authenticated identity; an explicit reason can be passed in the body. An exact retry returns the authoritative persisted decision without adding an approval or lifecycle event; a conflicting retry fails closed.",
Description: "Record an approval decision (approved or rejected) on a previously planned action. The actor is taken from the authenticated identity; an explicit reason can be passed in the body. Approval rechecks current expiry, policy, executor, resource-contract, and executor-owned availability gates before persistence, while rejection remains available when readiness is false. An exact retry returns the authoritative persisted decision without adding an approval or lifecycle event; a conflicting retry fails closed.",
Category: "action",
Method: http.MethodPost,
Path: ActionDecisionCapabilityPath,
+149 -11
View File
@@ -20,6 +20,7 @@ import (
const maxActionPlanRequestBytes = 1 << 20
const maxActionDecisionRequestBytes = 64 << 10
const maxActionExecutionRequestBytes = 64 << 10
const maxActionRefreshRequestBytes = 64 << 10
const maxActionForceFailRequestBytes = 64 << 10
const maxPendingActionAudits = 100
@@ -64,6 +65,10 @@ type actionExecutionRequest struct {
PlanHash string `json:"planHash,omitempty"`
}
type actionRefreshRequest struct {
PlanHash string `json:"planHash"`
}
// actionForceFailRequest carries the operator's justification for the
// override. It is optional and is recorded verbatim in the terminal result.
type actionForceFailRequest struct {
@@ -114,11 +119,12 @@ type actionInboxResponse struct {
}
type actionDetailResponse struct {
Audit actionAuditProjection `json:"audit"`
Events []unified.ActionLifecycleEvent `json:"events"`
Attempt *unified.ActionDispatchAttempt `json:"attempt,omitempty"`
Receipt *unified.ActionDispatchReceipt `json:"receipt,omitempty"`
ReadOnly bool `json:"readOnly"`
Audit actionAuditProjection `json:"audit"`
Events []unified.ActionLifecycleEvent `json:"events"`
Attempt *unified.ActionDispatchAttempt `json:"attempt,omitempty"`
Receipt *unified.ActionDispatchReceipt `json:"receipt,omitempty"`
Readiness actionlifecycle.ActionReadiness `json:"readiness"`
ReadOnly bool `json:"readOnly"`
}
// ActionLifecycle returns the shared transport-independent action lifecycle
@@ -139,6 +145,7 @@ func (h *ResourceHandlers) ActionLifecycle() *actionlifecycle.Service {
EmergencyStop: h.actionEmergencyStop,
DecisionAuthorizer: h.actionDecisionAuthorizer,
ExecutionAuthorizer: h.actionExecutionAuthorizer,
RefreshPlanner: h.actionRefreshPlanner,
}
}
@@ -291,8 +298,12 @@ func (h *ResourceHandlers) HandleGetAction(w http.ResponseWriter, r *http.Reques
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(actionDetailResponse{
Audit: projectActionAudit(fixture.Audit, mockActionResourceRegistry()),
Events: fixture.Events,
Audit: projectActionAudit(fixture.Audit, mockActionResourceRegistry()),
Events: fixture.Events,
Readiness: actionlifecycle.ActionReadiness{
Code: "mock_read_only", Message: "Mock actions are read-only.",
Remediation: "Use a live Pulse instance to approve or run actions.", CheckedAt: time.Now().UTC(),
},
ReadOnly: true,
}); err != nil {
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailEncodeFailed, "Failed to encode action detail")
@@ -315,10 +326,11 @@ func (h *ResourceHandlers) HandleGetAction(w http.ResponseWriter, r *http.Reques
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(actionDetailResponse{
Audit: h.projectActionAudit(GetOrgID(r.Context()), detail.Audit),
Events: detail.Events,
Attempt: detail.Attempt,
Receipt: detail.Receipt,
Audit: h.projectActionAudit(GetOrgID(r.Context()), detail.Audit),
Events: detail.Events,
Attempt: detail.Attempt,
Receipt: detail.Receipt,
Readiness: h.ActionLifecycle().AssessCurrentReadiness(r.Context(), GetOrgID(r.Context()), detail.Audit),
}); err != nil {
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailEncodeFailed, "Failed to encode action detail")
}
@@ -442,6 +454,9 @@ func (h *ResourceHandlers) HandleDecideAction(w http.ResponseWriter, r *http.Req
updated, err := lifecycle.Decide(r.Context(), orgID, actionID, canonicalDecision)
if err != nil {
writeActionLifecycleReadError(w, err, func() {
if writeActionReadinessError(w, err) {
return
}
var persist *actionlifecycle.PersistError
if errors.As(err, &persist) {
writeJSONError(w, http.StatusInternalServerError, "action_decision_persist_failed", sanitizeErrorForClient(err, "Failed to persist action decision"))
@@ -467,6 +482,129 @@ func (h *ResourceHandlers) HandleDecideAction(w http.ResponseWriter, r *http.Req
}
}
// HandleRefreshAction replaces an expired or drifted immutable plan with a
// newly validated plan. The reviewed hash binds the request to the record the
// operator actually saw; broker origin and current authority inputs are
// reconstructed only by trusted server code.
func (h *ResourceHandlers) HandleRefreshAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if mock.IsMockEnabled() {
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeMockModeEnabled, "Cannot refresh actions in mock mode")
return
}
actionID := strings.TrimSpace(r.PathValue("id"))
if actionID == "" || !validAuditEventID.MatchString(actionID) || len(actionID) > 128 {
writeJSONError(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidID, "Invalid action ID format")
return
}
var refresh actionRefreshRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxActionRefreshRequestBytes))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&refresh); err != nil {
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action refresh request", map[string]string{"body": "request body must contain the reviewed plan hash"})
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action refresh request", map[string]string{"body": "request body must contain one JSON object"})
return
}
refresh.PlanHash = strings.TrimSpace(refresh.PlanHash)
if refresh.PlanHash == "" {
writeJSONErrorWithDetails(w, http.StatusBadRequest, agentcapabilities.AgentErrCodeInvalidActionRequest, "Invalid action refresh request", map[string]string{"planHash": "reviewed plan hash is required"})
return
}
orgID := GetOrgID(r.Context())
actor, err := actionActorForRequest(h.cfg, r, orgID)
if err != nil {
writeJSONError(w, http.StatusForbidden, agentcapabilities.AgentErrCodeActionActorUnavailable, "Authenticated action actor is unavailable")
return
}
lifecycle := h.ActionLifecycle()
previous, found, err := lifecycle.Get(orgID, actionID)
if err != nil {
writeActionLifecycleReadError(w, err, func() {
writeJSONError(w, http.StatusInternalServerError, "action_audit_query_failed", "Failed to query action audit")
})
return
}
if !found {
writeJSONErrorWithDetails(w, http.StatusNotFound, agentcapabilities.AgentErrCodeActionNotFound, "Action not found", map[string]string{"actionId": actionID})
return
}
if refresh.PlanHash != previous.Plan.PlanHash {
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanIdentityMismatch, "The reviewed action plan changed; reload it before refreshing")
return
}
replacement, err := lifecycle.Refresh(r.Context(), orgID, actionID, actor)
if err != nil {
if errors.Is(err, actionlifecycle.ErrActionRefreshNotAllowed) {
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionRefreshNotAllowed, "Only expired or drifted action plans can be refreshed")
return
}
if writeActionReadinessError(w, err) {
return
}
writeActionPlanError(w, err)
return
}
detail, found, err := lifecycle.Detail(orgID, replacement.ID)
if err != nil || !found {
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailFailed, "Replacement action detail is unavailable")
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(actionDetailResponse{
Audit: h.projectActionAudit(orgID, detail.Audit),
Events: detail.Events,
Attempt: detail.Attempt,
Receipt: detail.Receipt,
Readiness: lifecycle.AssessCurrentReadiness(r.Context(), orgID, detail.Audit),
}); err != nil {
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionDetailEncodeFailed, "Failed to encode replacement action detail")
}
}
// writeActionReadinessError maps failures shared by approval and dispatch to
// the same stable client contract. Returning true indicates that the response
// was written.
func writeActionReadinessError(w http.ResponseWriter, err error) bool {
var availability *actionlifecycle.AvailabilityRefusedError
var freshness *actionlifecycle.FreshnessCheckError
var policy *actionlifecycle.PolicyCheckError
var availabilityCheck *actionlifecycle.AvailabilityCheckError
switch {
case errors.As(err, &availability):
writeJSONErrorWithDetails(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionExecutionUnavailable, "Action execution is unavailable", map[string]string{
"resourceId": availability.ResourceID,
"capabilityName": availability.CapabilityName,
"reasonCode": availability.Readiness.ReasonCode,
"reason": firstNonEmpty(availability.Readiness.Reason, "action execution is unavailable"),
})
case errors.Is(err, unified.ErrActionPlanDrift):
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionPlanDrift, "Action plan no longer matches the current resource contract; refresh the plan before continuing")
case errors.Is(err, unified.ErrActionEmergencyStop):
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionEmergencyStop, "Action dispatch is stopped by the operator")
case errors.Is(err, unified.ErrResourceRemediationLocked):
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeResourceRemediationLocked, "Resource is operator-locked against remediation")
case errors.Is(err, unified.ErrActionDryRunOnly):
writeJSONError(w, http.StatusConflict, agentcapabilities.AgentErrCodeActionDryRunOnly, "Action plan is dry-run only and cannot be approved for execution")
case errors.Is(err, actionlifecycle.ErrExecutorUnavailable):
writeJSONError(w, http.StatusServiceUnavailable, agentcapabilities.AgentErrCodeActionExecutorUnavailable, "No action executor is available")
case errors.As(err, &freshness):
writeJSONError(w, http.StatusInternalServerError, "action_plan_validation_failed", sanitizeErrorForClient(err, "Failed to validate action plan freshness"))
case errors.As(err, &policy):
writeJSONError(w, http.StatusInternalServerError, "action_policy_validation_failed", sanitizeErrorForClient(err, "Failed to validate action policy"))
case errors.As(err, &availabilityCheck):
writeJSONError(w, http.StatusInternalServerError, agentcapabilities.AgentErrCodeActionReadinessCheckFailed, sanitizeErrorForClient(err, "Failed to validate action execution availability"))
default:
return false
}
return true
}
func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+5
View File
@@ -499,6 +499,8 @@ func TestHandleListActionsRejectsUnknownViewAndUnsafeLimit(t *testing.T) {
func TestHandleDecideActionApprovesPendingPlanWithoutExecution(t *testing.T) {
now := time.Date(2026, 5, 4, 14, 0, 0, 0, time.UTC)
h := newActionTestResourceHandlers(t, &config.Config{DataPath: t.TempDir()})
executor := &stubActionExecutor{result: &unified.ExecutionResult{Success: true}}
h.SetActionExecutor(executor)
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: now},
resources: []unified.Resource{
@@ -569,6 +571,9 @@ func TestHandleDecideActionApprovesPendingPlanWithoutExecution(t *testing.T) {
if decision.Audit.Result != nil {
t.Fatalf("approval must not execute the action, got result %#v", decision.Audit.Result)
}
if executor.calls != 0 {
t.Fatalf("approval must not call the executor, calls=%d", executor.calls)
}
store, err := h.getStore("default")
if err != nil {
+2
View File
@@ -20668,6 +20668,8 @@ func TestContract_AgentSurfaceErrorCodesMatchManifestDeclarations(t *testing.T)
"AgentErrCodeActionReadinessCheckFailed": agentcapabilities.AgentErrCodeActionReadinessCheckFailed,
"AgentErrCodeActionPlanDrift": agentcapabilities.AgentErrCodeActionPlanDrift,
"AgentErrCodeActionPlanIdentityMismatch": agentcapabilities.AgentErrCodeActionPlanIdentityMismatch,
"AgentErrCodeActionRefreshNotAllowed": agentcapabilities.AgentErrCodeActionRefreshNotAllowed,
"AgentErrCodeActionEmergencyStop": agentcapabilities.AgentErrCodeActionEmergencyStop,
"AgentErrCodeResourceRemediationLocked": agentcapabilities.AgentErrCodeResourceRemediationLocked,
"AgentErrCodeMockModeEnabled": agentcapabilities.AgentErrCodeMockModeEnabled,
"AgentErrCodeActionExecutorUnavailable": agentcapabilities.AgentErrCodeActionExecutorUnavailable,
+42
View File
@@ -81,6 +81,48 @@ func NewPatrolActionBroker(orgID string, resources *ResourceHandlers, policy ...
return broker
}
// NewActionRefreshPlanner reconstructs trusted planning inputs for both
// operator-originated and broker-originated replacement plans. Patrol plans
// are rebound to the service actor and freshly evaluated policy authorities;
// the authenticated operator only requests the refresh and cannot author
// those fields.
func NewActionRefreshPlanner(resources *ResourceHandlers, policy PatrolActionPolicyProvider) actionlifecycle.RefreshPlanner {
return func(ctx context.Context, orgID string, previous unified.ActionAuditRecord, actor unified.ActionActor, requestID string) (unified.ActionRequest, actionlifecycle.PlanOptions, error) {
req := previous.Request
req.RequestID = requestID
req.Actor = unified.ActionActor{}
req.RequestedBy = ""
opts := actionlifecycle.PlanOptions{Actor: actor, Origin: previous.Origin}
if !isPatrolActionOrigin(previous.Origin) {
return req, opts, nil
}
proposal := aicontracts.ActionProposal{
ProposalID: requestID,
FindingID: previous.Origin.FindingID,
InvestigationID: previous.Origin.InvestigationID,
ResourceID: previous.Request.ResourceID,
CapabilityName: previous.Request.CapabilityName,
Params: previous.Request.Params,
Reason: previous.Request.Reason,
EvidenceIDs: append([]string(nil), previous.Origin.EvidenceIDs...),
}
broker := &patrolActionBroker{orgID: orgID, lifecycle: resources.ActionLifecycle, policy: policy}
if err := broker.rejectSensitiveParams(ctx, proposal); err != nil {
return unified.ActionRequest{}, actionlifecycle.PlanOptions{}, err
}
factors, _ := broker.planPolicyFactors(ctx, proposal, broker.currentTime())
req.RequestedBy = patrolActionBrokerActor
opts.Actor = unified.ActionActor{
SubjectID: patrolActionBrokerActor,
Kind: unified.ActionActorService,
CredentialID: "service:patrol-action-broker",
OrgID: orgID,
}
opts.PolicyFactors = factors
return req, opts, nil
}
}
func (b *patrolActionBroker) Capabilities(ctx context.Context, resourceID string) (aicontracts.ActionCapabilityCatalog, error) {
resourceID = unified.CanonicalResourceID(resourceID)
capabilities, err := b.lifecycle().Capabilities(ctx, b.orgID, resourceID)
+94
View File
@@ -3,6 +3,7 @@ package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
@@ -140,6 +141,99 @@ func TestPatrolActionBrokerSnapshotsTenantResourceAndCapabilityPolicyAtPlanTime(
}
}
func TestPatrolApprovalRefusesLostReadinessBeforeRecordingDecision(t *testing.T) {
h, executor := newPatrolBrokerTestHandlers(t, unified.ApprovalAdmin)
disposition, err := NewPatrolActionBroker("default", h).Submit(context.Background(), patrolTestProposal())
if err != nil {
t.Fatal(err)
}
executor.readiness = &unified.ResourceActionReadiness{
Name: "restart",
Available: false,
ReasonCode: "command_agent_disconnected",
Reason: "Connect the command agent for web-42.",
}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/actions/"+disposition.ActionID+"/decision", bytes.NewBufferString(`{"outcome":"approved"}`))
request.SetPathValue("id", disposition.ActionID)
h.HandleDecideAction(recorder, actionHandlerTestRequest(request, "operator@example.com"))
if recorder.Code != http.StatusConflict {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var envelope struct {
Error string `json:"error"`
Details map[string]string `json:"details"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if envelope.Error != "action_execution_unavailable" || envelope.Details["reasonCode"] != "command_agent_disconnected" || envelope.Details["reason"] != "Connect the command agent for web-42." {
t.Fatalf("envelope=%#v", envelope)
}
record, found, err := h.ActionLifecycle().Get("default", disposition.ActionID)
if err != nil || !found || record.State != unified.ActionStatePending || len(record.Approvals) != 0 || executor.calls != 0 {
t.Fatalf("record=%#v found=%v err=%v calls=%d", record, found, err, executor.calls)
}
}
func TestPatrolRefreshRebindsCurrentPolicyAndTrustedOrigin(t *testing.T) {
h, _ := newPatrolBrokerTestHandlers(t, unified.ApprovalAdmin)
policyVersion := "tenant-v1"
policy := func(context.Context, string) (PatrolActionPolicySnapshot, error) {
return PatrolActionPolicySnapshot{EffectiveAutonomyLevel: "monitor", PolicyVersion: policyVersion}, nil
}
disposition, err := NewPatrolActionBroker("default", h, policy).Submit(context.Background(), patrolTestProposal())
if err != nil {
t.Fatal(err)
}
previous, found, err := h.ActionLifecycle().Get("default", disposition.ActionID)
if err != nil || !found {
t.Fatalf("previous found=%v err=%v", found, err)
}
registry, err := h.buildRegistry("default")
if err != nil {
t.Fatal(err)
}
resource, found := registry.Get(previous.Request.ResourceID)
if !found || resource == nil {
t.Fatal("planned resource missing")
}
changed := *resource
changed.UpdatedAt = changed.UpdatedAt.Add(time.Second)
changed.Name = changed.Name + " refreshed"
h.SetStateProvider(resourceUnifiedSeedProvider{
snapshot: models.StateSnapshot{LastUpdate: changed.UpdatedAt},
resources: []unified.Resource{changed},
})
h.cacheMu.Lock()
h.registryCache = make(map[string]registryCacheEntry)
h.cacheMu.Unlock()
policyVersion = "tenant-v2"
h.SetActionRefreshPlanner(NewActionRefreshPlanner(h, policy))
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/actions/"+previous.ID+"/refresh", bytes.NewBufferString(`{"planHash":"`+previous.Plan.PlanHash+`"}`))
request.SetPathValue("id", previous.ID)
h.HandleRefreshAction(recorder, actionHandlerTestRequest(request, "operator@example.com"))
if recorder.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
var detail actionDetailResponse
if err := json.Unmarshal(recorder.Body.Bytes(), &detail); err != nil {
t.Fatal(err)
}
replacement := detail.Audit.ActionAuditRecord
if replacement.ID == previous.ID || replacement.Request.RequestID != "refresh:"+previous.ID || replacement.Request.Actor.SubjectID != patrolActionBrokerActor {
t.Fatalf("replacement=%#v", replacement)
}
if replacement.Origin == nil || replacement.Origin.FindingID != previous.Origin.FindingID || replacement.Origin.InvestigationID != previous.Origin.InvestigationID {
t.Fatalf("origin=%#v", replacement.Origin)
}
if !detail.Readiness.Ready || len(replacement.Plan.PolicyDecision.Authorities) != 3 || replacement.Plan.PolicyDecision.Authorities[1].Revision == previous.Plan.PolicyDecision.Authorities[1].Revision {
t.Fatalf("readiness=%#v policy=%#v", detail.Readiness, replacement.Plan.PolicyDecision)
}
}
func containsPolicyReason(reasons []unified.ActionPolicyReasonCode, target unified.ActionPolicyReasonCode) bool {
for _, reason := range reasons {
if reason == target {
+7
View File
@@ -40,6 +40,7 @@ type ResourceHandlers struct {
actionEmergencyStop func(orgID string) (bool, error)
actionDecisionAuthorizer actionlifecycle.DecisionAuthorizer
actionExecutionAuthorizer actionlifecycle.ExecutionAuthorizer
actionRefreshPlanner actionlifecycle.RefreshPlanner
discoveryReadiness ResourceDiscoveryReadinessProvider
}
@@ -119,6 +120,12 @@ func (h *ResourceHandlers) SetActionAuthorizers(decision actionlifecycle.Decisio
h.actionExecutionAuthorizer = execution
}
// SetActionRefreshPlanner installs the trusted reconstruction hook used when
// a broker-originated immutable plan must be replaced.
func (h *ResourceHandlers) SetActionRefreshPlanner(planner actionlifecycle.RefreshPlanner) {
h.actionRefreshPlanner = planner
}
// SetActionCompletedPublisher configures the terminal action notification hook
// used by the agent SSE bridge. It is intentionally outside the execution
// driver so refused-before-dispatch failures and future executor
+1
View File
@@ -436,6 +436,7 @@ var allRouteAllowlist = []string{
"POST /api/actions/{id}/decision",
"POST /api/actions/{id}/execute",
"POST /api/actions/{id}/force-fail",
"POST /api/actions/{id}/refresh",
"/api/guests/metadata",
"/api/guests/metadata/",
"/api/docker/metadata",
+19 -17
View File
@@ -744,24 +744,26 @@ func (r *Router) setupRoutes() {
r.aiSettingsHandler.SetPolicyMutationCoordinator(r.resourceHandlers.ActionLifecycle().WithPolicyMutation)
r.resourceHandlers.SetActionTransitionPublisher(r.aiSettingsHandler.ReconcilePatrolActionTransition)
resourceHandlers := r.resourceHandlers
patrolPolicyProvider := func(ctx context.Context, scopedOrgID string) (PatrolActionPolicySnapshot, error) {
orgCtx := context.WithValue(ctx, OrgIDContextKey, approval.NormalizeOrgID(scopedOrgID))
svc := r.aiSettingsHandler.GetAIService(orgCtx)
if svc == nil {
return PatrolActionPolicySnapshot{}, nil
}
cfg := svc.GetConfig()
if cfg == nil {
return PatrolActionPolicySnapshot{}, nil
}
effectiveAutonomyLevel := svc.GetEffectivePatrolAutonomyLevel()
return PatrolActionPolicySnapshot{
EffectiveAutonomyLevel: effectiveAutonomyLevel,
FullModeUnlocked: effectiveAutonomyLevel == config.PatrolAutonomyFull,
EmergencyStop: cfg.PatrolActionEmergencyStop,
}, nil
}
resourceHandlers.SetActionRefreshPlanner(NewActionRefreshPlanner(resourceHandlers, patrolPolicyProvider))
r.aiSettingsHandler.SetActionBrokerFactory(func(orgID string) aicontracts.OrchestratorActionBroker {
return NewPatrolActionBroker(orgID, resourceHandlers, func(ctx context.Context, scopedOrgID string) (PatrolActionPolicySnapshot, error) {
orgCtx := context.WithValue(ctx, OrgIDContextKey, approval.NormalizeOrgID(scopedOrgID))
svc := r.aiSettingsHandler.GetAIService(orgCtx)
if svc == nil {
return PatrolActionPolicySnapshot{}, nil
}
cfg := svc.GetConfig()
if cfg == nil {
return PatrolActionPolicySnapshot{}, nil
}
effectiveAutonomyLevel := svc.GetEffectivePatrolAutonomyLevel()
return PatrolActionPolicySnapshot{
EffectiveAutonomyLevel: effectiveAutonomyLevel,
FullModeUnlocked: effectiveAutonomyLevel == config.PatrolAutonomyFull,
EmergencyStop: cfg.PatrolActionEmergencyStop,
}, nil
})
return NewPatrolActionBroker(orgID, resourceHandlers, patrolPolicyProvider)
})
r.aiSettingsHandler.SetProposalCatalogFactory(func(orgID string) tools.ProposalCatalog {
return func(ctx context.Context, resourceID string) ([]unifiedresources.ResourceCapability, error) {
+4
View File
@@ -151,6 +151,10 @@ func (r *Router) registerMonitoringResourceRoutes(
r.mux.HandleFunc("GET /api/actions/{id}", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionDetail,
requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleGetAction),
)))
r.mux.HandleFunc("POST /api/actions/{id}/refresh", RequireAuth(r.config, RequireAnyScope([]string{config.ScopeActionsPlan, config.ScopeAIExecute}, r.withExternalAgentCapabilityActivity(
agentcapabilities.PlanActionCapabilityName,
requireActionCapability(r.authorizer, auth.ActionPlan, r.resourceHandlers.HandleRefreshAction),
))))
r.mux.HandleFunc("POST /api/actions/{id}/decision", RequireAuth(r.config, requireRelayMobileRuntimeRoute(relayMobileRouteActionDecision, r.withExternalAgentCapabilityActivity(
agentcapabilities.DecideActionCapabilityName,
requireActionCapability(r.authorizer, auth.ActionApprove, r.resourceHandlers.HandleDecideAction),
+1
View File
@@ -9,6 +9,7 @@
{"id":"action.api.execute","origin":"api","resource_class":"customer_infrastructure","resource_kind":"unified-resource","capability":"execute_action","entrypoint":"POST /api/actions/{id}/execute","disposition":"lifecycle","lifecycle_executor":"internal/actionlifecycle.Service.Execute","approval_floor":"admin","delivery":"committed_lifecycle_before_transport","verification":"required","rollback":"task_10_truth_and_compensation","residual_owners":["task_07_durable_delivery","task_10_truth_and_compensation"]},
{"id":"action.api.force-fail","origin":"api","resource_class":"customer_infrastructure","resource_kind":"unified-resource","capability":"execute_action","entrypoint":"POST /api/actions/{id}/force-fail","disposition":"lifecycle","lifecycle_executor":"internal/actionlifecycle.Service.ForceFail","approval_floor":"admin","delivery":"committed_lifecycle_before_transport","verification":"required","rollback":"task_10_truth_and_compensation","residual_owners":["task_10_truth_and_compensation"]},
{"id":"action.api.plan","origin":"api","resource_class":"customer_infrastructure","resource_kind":"unified-resource","capability":"plan_action","entrypoint":"POST /api/actions/plan","disposition":"lifecycle","lifecycle_executor":"internal/actionlifecycle.Service.PlanWithOptions","approval_floor":"policy_or_admin","delivery":"committed_lifecycle_before_transport","verification":"required","rollback":"task_10_truth_and_compensation","residual_owners":["task_10_truth_and_compensation"]},
{"id":"action.api.refresh","origin":"api","resource_class":"customer_infrastructure","resource_kind":"unified-resource","capability":"plan_action","entrypoint":"POST /api/actions/{id}/refresh","disposition":"lifecycle","lifecycle_executor":"internal/actionlifecycle.Service.Refresh","approval_floor":"policy_or_admin","delivery":"committed_lifecycle_before_transport","verification":"required","rollback":"task_10_truth_and_compensation","residual_owners":["task_10_truth_and_compensation"]},
{"id":"assistant.docker.control","origin":"model","resource_class":"customer_infrastructure","resource_kind":"app-container","capability":"start_stop_restart","entrypoint":"pulse_docker action=control","disposition":"retired_denied","approval_floor":"admin","delivery":"denied_before_transport","verification":"denied","rollback":"denied","residual_owners":["task_05_typed_lifecycle_migration"]},
{"id":"assistant.docker.update","origin":"model","resource_class":"customer_infrastructure","resource_kind":"app-container","capability":"update_container","entrypoint":"pulse_docker action=update","disposition":"retired_denied","approval_floor":"admin","delivery":"denied_before_transport","verification":"denied","rollback":"denied","residual_owners":["task_07_durable_delivery","task_10_truth_and_compensation"]},
@@ -7,6 +7,7 @@ var generatedEntries = []Entry{
{ID: "action.api.execute", Origin: Origin("api"), ResourceClass: ResourceClass("customer_infrastructure"), ResourceKind: "unified-resource", Capability: "execute_action", Entrypoint: "POST /api/actions/{id}/execute", Disposition: Disposition("lifecycle"), LifecycleExecutor: "internal/actionlifecycle.Service.Execute", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("committed_lifecycle_before_transport"), Verification: VerificationClass("required"), Rollback: RollbackClass("task_10_truth_and_compensation"), ResidualOwners: []string{"task_07_durable_delivery", "task_10_truth_and_compensation"}},
{ID: "action.api.force-fail", Origin: Origin("api"), ResourceClass: ResourceClass("customer_infrastructure"), ResourceKind: "unified-resource", Capability: "execute_action", Entrypoint: "POST /api/actions/{id}/force-fail", Disposition: Disposition("lifecycle"), LifecycleExecutor: "internal/actionlifecycle.Service.ForceFail", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("committed_lifecycle_before_transport"), Verification: VerificationClass("required"), Rollback: RollbackClass("task_10_truth_and_compensation"), ResidualOwners: []string{"task_10_truth_and_compensation"}},
{ID: "action.api.plan", Origin: Origin("api"), ResourceClass: ResourceClass("customer_infrastructure"), ResourceKind: "unified-resource", Capability: "plan_action", Entrypoint: "POST /api/actions/plan", Disposition: Disposition("lifecycle"), LifecycleExecutor: "internal/actionlifecycle.Service.PlanWithOptions", Approval: ApprovalFloor("policy_or_admin"), Delivery: DeliveryClass("committed_lifecycle_before_transport"), Verification: VerificationClass("required"), Rollback: RollbackClass("task_10_truth_and_compensation"), ResidualOwners: []string{"task_10_truth_and_compensation"}},
{ID: "action.api.refresh", Origin: Origin("api"), ResourceClass: ResourceClass("customer_infrastructure"), ResourceKind: "unified-resource", Capability: "plan_action", Entrypoint: "POST /api/actions/{id}/refresh", Disposition: Disposition("lifecycle"), LifecycleExecutor: "internal/actionlifecycle.Service.Refresh", Approval: ApprovalFloor("policy_or_admin"), Delivery: DeliveryClass("committed_lifecycle_before_transport"), Verification: VerificationClass("required"), Rollback: RollbackClass("task_10_truth_and_compensation"), ResidualOwners: []string{"task_10_truth_and_compensation"}},
{ID: "admin.agent.deployment", Origin: Origin("api"), ResourceClass: ResourceClass("pulse_administration"), ResourceKind: "agent-enrollment", Capability: "deploy_agent", Entrypoint: "agentexec deploy_install/deploy_cancel", Disposition: Disposition("administrative_exception"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("administrative_transaction"), Verification: VerificationClass("administrative"), Rollback: RollbackClass("unsupported"), ResidualOwners: []string{"task_12_final_governance"}},
{ID: "admin.inventory.docker-runtime", Origin: Origin("api"), ResourceClass: ResourceClass("pulse_administration"), ResourceKind: "docker-runtime-record", Capability: "manage_inventory", Entrypoint: "/api/agents/docker/runtimes/* metadata and enrollment", Disposition: Disposition("administrative_exception"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("administrative_transaction"), Verification: VerificationClass("administrative"), Rollback: RollbackClass("unsupported"), ResidualOwners: []string{"task_12_final_governance"}},
{ID: "admin.inventory.kubernetes-cluster", Origin: Origin("api"), ResourceClass: ResourceClass("pulse_administration"), ResourceKind: "kubernetes-cluster-record", Capability: "manage_inventory", Entrypoint: "/api/agents/kubernetes/clusters/* metadata and enrollment", Disposition: Disposition("administrative_exception"), LifecycleExecutor: "", Approval: ApprovalFloor("admin"), Delivery: DeliveryClass("administrative_transaction"), Verification: VerificationClass("administrative"), Rollback: RollbackClass("unsupported"), ResidualOwners: []string{"task_12_final_governance"}},
@@ -16,6 +16,7 @@ type routeClassification struct {
var infrastructureRouteCatalog = map[string]routeClassification{
"POST /api/actions/plan": {MutationID: "action.api.plan"},
"POST /api/actions/{id}/refresh": {MutationID: "action.api.refresh"},
"GET /api/actions/pending": {},
"GET /api/actions": {},
"GET /api/actions/{id}": {},