Extract the transport-independent action lifecycle service

Planning, approval decisions, and execution for typed resource actions
move out of the HTTP handlers in internal/api/actions.go into a new
internal/actionlifecycle.Service owned by api-contracts. The REST
handlers become thin decode/actor/error-mapping adapters over the one
shared service, and ResourceHandlers.ActionLifecycle() exposes the same
service for in-process consumers, so a future Patrol action broker
inherits identical resource lookup, availability checks, plan hashing,
audit persistence, remediation locks, plan-drift revalidation,
execution, and terminal publication instead of loopback HTTP or a
parallel lifecycle.

Behavior is preserved: same status codes, error codes, and audit/
lifecycle persistence ordering, backed by the existing api contract
tests plus new fail-closed proofs for the service itself (unknown
resource/capability, availability refusal, unapproved execution,
remediation lock, plan drift, missing executor, missing store).

Contract text in api-contracts, agent-lifecycle, and storage-recovery
now names the service alongside actions.go and planner.go; the
subsystem registry owns internal/actionlifecycle/ under api-contracts
with a dedicated path policy; the code-standards and contract source
pins follow the moved invariants; and the subsystem_lookup line-number
pin shifts with the api-contracts canonical-files list insertion.

This is the first slice of making the typed action lifecycle the only
autonomous execution route for Patrol, Assistant, and MCP.
This commit is contained in:
rcourtman
2026-07-10 11:06:55 +01:00
parent 3c252918b3
commit f356994869
19 changed files with 2097 additions and 362 deletions
@@ -639,16 +639,28 @@ func TestActionExecutionContractStaysAPIOwned(t *testing.T) {
filepath.Join(".", "types.go"): {
"ActionReadiness []ResourceActionReadiness `json:\"actionReadiness,omitempty\"`",
},
filepath.Join("..", "api", "actions.go"): {
"type ActionExecutor interface",
"type ActionAvailabilityChecker interface",
filepath.Join("..", "actionlifecycle", "service.go"): {
// The transport-independent lifecycle service is the only
// sanctioned path from a typed action request to execution.
// REST handlers and in-process brokers must both route
// through it; pin its execution-boundary invariants here.
"type Executor interface",
"type AvailabilityChecker interface",
"CheckActionAvailable(ctx context.Context, req unified.ActionRequest, resource unified.Resource) unified.ResourceActionReadiness",
"func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Request)",
"func (h *ResourceHandlers) validateActionPlanFresh(orgID string, record unified.ActionAuditRecord) error",
"func recordRefusedActionExecution(store unified.ResourceStore, record unified.ActionAuditRecord",
"func (h *ResourceHandlers) publishActionCompleted(record unified.ActionAuditRecord)",
"func (s *Service) ValidatePlanFresh(orgID string, record unified.ActionAuditRecord) error",
"func RecordRefusedExecution(store Store, record unified.ActionAuditRecord",
"func (s *Service) publishCompleted(record unified.ActionAuditRecord)",
"store.RecordActionExecutionStart(started, startEvent)",
"store.RecordActionExecutionResult(completed, doneEvent)",
},
filepath.Join("..", "api", "actions.go"): {
// The REST layer is a thin adapter over the shared lifecycle
// service; it owns only decode, actor resolution, and error
// code mapping.
"type ActionExecutor = actionlifecycle.Executor",
"type ActionAvailabilityChecker = actionlifecycle.AvailabilityChecker",
"func (h *ResourceHandlers) ActionLifecycle() *actionlifecycle.Service",
"func (h *ResourceHandlers) HandleExecuteAction(w http.ResponseWriter, r *http.Request)",
"agentcapabilities.AgentErrCodeActionExecutionUnavailable",
"agentcapabilities.AgentErrCodeActionPlanDrift",
"agentcapabilities.AgentErrCodeActionExecutorUnavailable",