Files
pulse/internal/api/agent_capabilities.go
T
rcourtman 728c42e47b Bring action endpoints onto the agent surface with the agent-stable envelope
Closes the last known gap in the agent substrate. The three
action endpoints (POST /api/actions/plan, /api/actions/{id}/decision,
/api/actions/{id}/execute) previously emitted the platform-wide
APIError shape (stable code under "code", human under "error").
The agent surface uses the inverted shape (stable code under
"error", human under "message"), so adding action capabilities
to the manifest as-is would have forced agents to remember which
envelope each capability uses.

The slice refactors actions.go to emit the agent-stable envelope
across all 42 writeErrorResponse call sites. writeJSONError gains
a writeJSONErrorWithDetails sibling so the 13 calls that pass
field-level reasons (validation failures) preserve that
information under a new optional `details` field. The action
endpoints' JSON shape becomes:

  {"error": "<stable_code>", "message": "<human>",
   "details"?: {"<field>": "<reason>"}}

Frontend impact: zero. Verified that no frontend code consumes
the three action endpoints; the refactor is API-only.

Three new manifest entries (plan_action, decide_action,
execute_action) under a new "action" category, with their
declared error codes pinned per capability. Internal-failure 5xx
codes (audit-store outages, encode failures) are not declared
per capability; agents branch on 5xx generically.
TestContract_AgentSurfaceErrorCodesMatchManifestDeclarations now
audits actions.go alongside the existing two handler files, with
a documented internal-only allowlist for the 5xx codes.

The TestAgentSubstrate_ActionEndpointsEmitAgentStableEnvelope e2e
test exercises one error path through each endpoint via the actual
HTTP boundary, asserting the agent-stable envelope reaches the
wire and the legacy APIError fields (code, status_code, timestamp)
do NOT — drift back would mean the refactor regressed.

The TestContract_ActionDryRunOnlyExecutionErrorJSONSnapshot pin
is updated to match the new envelope shape; the manifest's
category allowlist gains "action".

api-contracts.md documents the new envelope (with details map),
the action governance loop's place in the substrate, the
ai:execute scope distinction from monitoring:write, and the
"manifest projection has a footnote" trade-off: bringing an
existing endpoint into the agent surface may require migrating
its error envelope, but the substrate keeps a single envelope
contract rather than carrying a translation wrapper layer.
agent-lifecycle.md and storage-recovery.md document the action
surface joining the agent paradigm and its zero-new-persistence
posture respectively. AGENT_SUBSTRATE.md's "what it does not do
yet" no longer lists the action surface; it now reflects the
real outstanding items (consumer feedback, an in-Pulse agent
integrations panel, a distribution path for pulse-mcp).
2026-05-10 15:16:17 +01:00

246 lines
13 KiB
Go

package api
import (
"encoding/json"
"net/http"
)
// AgentCapability declares one agent-consumable capability Pulse
// exposes — a single point of "here's what you can do and how." The
// shape is intentionally narrow so a future MCP-server slice can
// consume the manifest to register tools, and so external agents
// (Claude Code, custom integrations) can introspect Pulse without
// reading documentation. Each capability names the canonical REST
// path, the method, the scope required, and the stable error
// codes the response may carry — agents branch on those, not on
// human messages.
type AgentCapability struct {
// Name is the agent-stable identifier for this capability.
// snake_case to match the convention agents use for tool names.
// MUST be stable across releases — renaming breaks integrations.
Name string `json:"name"`
// Description is a single-sentence explanation an agent can
// surface to a user when it's deciding whether to call this
// capability. Phrased imperatively ("Get the situated context for
// a resource") rather than narratively.
Description string `json:"description"`
// Category groups capabilities for agent UIs. Stable values:
// "context" (read-only situated reads), "operator-state"
// (per-resource intent writes), "finding" (per-finding lifecycle
// actions). Agents can filter the manifest by category.
Category string `json:"category"`
// Method + Path describe the canonical REST surface. Path
// segments in `{braces}` are agent-supplied parameters; agents
// percent-encode them when they substitute (the canonical IDs
// commonly contain colons).
Method string `json:"method"`
Path string `json:"path"`
// Scope names the auth scope required to call this capability.
// Agents that lack the scope must ask the operator to widen
// their token rather than retrying.
Scope string `json:"scope"`
// ResponseShape is the agent-stable name of the response type
// (e.g. "AgentResourceContext", "ResourceOperatorState"). Agents
// branch on this to know what to parse; future MCP integrations
// can map it to tool result schemas.
ResponseShape string `json:"responseShape,omitempty"`
// ErrorCodes is the closed set of stable error codes this
// capability may return on failure. Agents branch on these
// (e.g. "operator_state_not_set", "operator_state_invalid",
// "resource_remediation_locked", "plan_drift") rather than
// parsing human messages. Empty list = the capability uses only
// generic HTTP-status error responses.
ErrorCodes []string `json:"errorCodes,omitempty"`
// RequestBodyShape, when non-empty, names the agent-stable
// request body shape for non-GET capabilities so agents can
// validate before sending.
RequestBodyShape string `json:"requestBodyShape,omitempty"`
}
// AgentCapabilitiesManifest is the discovery document for Pulse's
// agent surface. Any agent that wants to integrate with Pulse fetches
// this once at startup, learns what's available, and calls the named
// capabilities. The manifest itself is read-only and unauthenticated
// (the Pulse capabilities it describes have their own auth scopes).
type AgentCapabilitiesManifest struct {
// Version pins the manifest contract. Agents validate they
// understand this version before using the manifest. Bumping
// version is reserved for breaking shape changes; additive
// capabilities ship under the same version.
Version string `json:"version"`
// Capabilities is the canonical list. Order is stable across
// requests so agents can diff snapshots cheaply.
Capabilities []AgentCapability `json:"capabilities"`
}
// agentCapabilitiesManifest is the v1 declaration of Pulse's
// agent-consumable surface. Hand-authored rather than auto-generated
// because the contract decisions (which capabilities are
// agent-stable, what the stable error codes are, what category each
// belongs to) are product-shaping and must not drift behind code
// changes. Adding a capability here is a deliberate "this is part of
// the agent surface" commitment.
var agentCapabilitiesManifest = AgentCapabilitiesManifest{
Version: "v1",
Capabilities: []AgentCapability{
{
Name: "get_resource_context",
Description: "Return the situated picture of a resource — identity, operator-set state with maintenance-window-active flag, active findings, pending approvals scoped to this resource, recent actions including refused dispatches.",
Category: "context",
Method: http.MethodGet,
Path: "/api/agent/resource-context/{resourceId}",
Scope: "monitoring:read",
ResponseShape: "AgentResourceContext",
ErrorCodes: []string{"resource_not_found"},
},
{
Name: "get_fleet_context",
Description: "Return a thin per-resource triage rollup across every resource visible to the org — identity, operator flags (intentionallyOffline, neverAutoRemediate, maintenanceWindowActive), per-severity finding counts (total/critical/warning/info), and pending-approval count. One read for 'where do I focus?'; follow up via get_resource_context for depth.",
Category: "context",
Method: http.MethodGet,
Path: "/api/agent/fleet-context",
Scope: "monitoring:read",
ResponseShape: "AgentFleetContext",
},
{
Name: "get_operator_state",
Description: "Read the operator-set state for a resource (intentionally offline, never auto-remediate, maintenance window, criticality).",
Category: "operator-state",
Method: http.MethodGet,
Path: "/api/resources/{resourceId}/operator-state",
Scope: "monitoring:read",
ResponseShape: "ResourceOperatorState",
ErrorCodes: []string{"operator_state_not_set"},
},
{
Name: "set_operator_state",
Description: "Replace the operator-set state for a resource. URL canonicalId wins over body; server populates setAt and setBy from the authenticated identity.",
Category: "operator-state",
Method: http.MethodPut,
Path: "/api/resources/{resourceId}/operator-state",
Scope: "monitoring:write",
RequestBodyShape: "ResourceOperatorStateInput",
ResponseShape: "ResourceOperatorState",
ErrorCodes: []string{"operator_state_invalid"},
},
{
Name: "clear_operator_state",
Description: "Remove any operator-set state for a resource. Idempotent — succeeds whether or not an entry was present.",
Category: "operator-state",
Method: http.MethodDelete,
Path: "/api/resources/{resourceId}/operator-state",
Scope: "monitoring:write",
},
{
Name: "subscribe_events",
Description: "Subscribe to the SSE event stream for real-time notifications: finding.created when a new finding is raised, approval.pending when a remediation request enters StatusPending and waits on operator decision, action.completed when an action audit reaches a terminal state (Completed or Failed, including refused-before-dispatch failures with stable error-token prefixes; carries a verification block with the read-after-write probe outcome so agents close the certainty loop without polling /api/actions/{id}), heartbeat every 15s. Long-lived connection; agents listen instead of polling.",
Category: "context",
Method: http.MethodGet,
Path: "/api/agent/events",
Scope: "monitoring:read",
ResponseShape: "text/event-stream of AgentEvent",
},
{
Name: "list_findings",
Description: "List all Patrol findings (active, dismissed, resolved). Filter client-side on returned shape.",
Category: "finding",
Method: http.MethodGet,
Path: "/api/ai/patrol/findings",
Scope: "monitoring:read",
ResponseShape: "Finding[]",
},
{
Name: "acknowledge_finding",
Description: "Mark a finding as seen but keep it visible. Auto-resolves when the underlying condition clears.",
Category: "finding",
Method: http.MethodPost,
Path: "/api/ai/patrol/acknowledge",
Scope: "monitoring:write",
RequestBodyShape: "{ finding_id: string }",
},
{
Name: "snooze_finding",
Description: "Hide a finding for a defined duration in hours.",
Category: "finding",
Method: http.MethodPost,
Path: "/api/ai/patrol/snooze",
Scope: "monitoring:write",
RequestBodyShape: "{ finding_id: string, duration_hours: number }",
},
{
Name: "dismiss_finding",
Description: "Dismiss a finding with a reason: not_an_issue (permanent suppression), expected_behavior (acknowledged forever), or will_fix_later (7-day reminder commitment).",
Category: "finding",
Method: http.MethodPost,
Path: "/api/ai/patrol/dismiss",
Scope: "monitoring:write",
RequestBodyShape: "{ finding_id: string, reason: \"not_an_issue\"|\"expected_behavior\"|\"will_fix_later\", note?: string }",
},
{
Name: "resolve_finding",
Description: "Manually mark a finding as resolved when the underlying issue has been fixed out-of-band.",
Category: "finding",
Method: http.MethodPost,
Path: "/api/ai/patrol/resolve",
Scope: "monitoring:write",
RequestBodyShape: "{ finding_id: string }",
},
{
Name: "plan_action",
Description: "Plan an action against a resource. The planner validates the request, looks up the capability on the resource, 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 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.",
Category: "action",
Method: http.MethodPost,
Path: "/api/actions/plan",
Scope: "ai:execute",
RequestBodyShape: "ActionRequest",
ResponseShape: "ActionPlan",
ErrorCodes: []string{"invalid_action_request", "resource_not_found", "capability_not_found"},
},
{
Name: "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. Idempotent on the persisted decision: re-deciding a non-pending action surfaces the action_not_pending stable code so agents can branch on the conflict rather than retrying blindly.",
Category: "action",
Method: http.MethodPost,
Path: "/api/actions/{actionId}/decision",
Scope: "ai:execute",
RequestBodyShape: "{ outcome: \"approved\"|\"rejected\", reason?: string }",
ResponseShape: "ActionDecisionResponse",
ErrorCodes: []string{"missing_id", "invalid_id", "invalid_action_decision", "action_not_found", "action_not_pending", "action_plan_expired"},
},
{
Name: "execute_action",
Description: "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) or when the API instance has no executor wired (action_executor_unavailable). action.completed SSE events fire on every terminal state so agents watching the stream do not need to poll this endpoint after dispatch.",
Category: "action",
Method: http.MethodPost,
Path: "/api/actions/{actionId}/execute",
Scope: "ai:execute",
RequestBodyShape: "{ reason?: string }",
ResponseShape: "ActionExecutionResponse",
ErrorCodes: []string{"missing_id", "invalid_id", "invalid_action_execution", "action_not_found", "action_not_approved", "action_already_executing", "action_execution_final", "action_dry_run_only", "action_executor_unavailable"},
},
},
}
// HandleAgentCapabilitiesManifest serves
// `GET /api/agent/capabilities` — the discovery document for Pulse's
// agent surface. Cacheable, unauthenticated (the underlying
// capabilities have their own scopes); agents fetch this once and
// learn what's available.
func HandleAgentCapabilitiesManifest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=300")
_ = json.NewEncoder(w).Encode(agentCapabilitiesManifest)
}