Commit B (pulse): typed lifecycle is the only Patrol route; delete the command side doors

OrchestratorChatService is replaced, not extended: it exposes only
ExecuteInvestigationStream (returning a structured
OrchestratorInvestigationResult) and ListInvestigationTools. The generic
ExecuteStream, SetAutonomousMode, ListAvailableTools, and the
AutonomousMode request field are gone, along with
OrchestratorCommandExecutor, OrchestratorApprovalStore, and the
autonomy/fix-verifier/license dependency interfaces. OrchestratorDeps
now carries a REQUIRED ActionBroker and no command/autonomy deps.

The pulse investigation adapter is rewritten to drive
ExecuteInvestigationStream, injecting proposal/finding/investigation
identity from trusted context and feeding a per-org proposal catalog
resolved from the tenant-bound action lifecycle (so acceptance and
planning validate identically). The retired command-execution,
autonomy, and command-shaped approval adapters are deleted; the router
wires the broker and catalog factories onto the AI settings handler.

The sixth side door is removed: PatrolService.generateRemediation-
PlanFromInvestigation and its call, which copied Fix.Commands into an
executable enterprise remediation plan, are deleted, pinned closed by a
source-audit test.

ActionCapabilityParamInfo gains Pattern (mapped by the broker), so the
canonical planner's pattern validation survives the cross-repo
boundary.

L20 readiness assertion RA35 asserts every Patrol-initiated
infrastructure mutation originates as a typed proposal and reaches
execution only through the canonical action lifecycle, with unsupported
proposals failing closed. Contract prose updated across ai-runtime,
api-contracts, agent-lifecycle, performance-and-scalability,
security-privacy, and storage-recovery.

Lands together with the pulse-enterprise orchestrator migration (the
replace directive means both heads move as one window).
This commit is contained in:
rcourtman
2026-07-10 18:05:25 +01:00
parent 8fbbed4d05
commit a1ba51630d
18 changed files with 461 additions and 430 deletions
+98 -1
View File
@@ -3946,6 +3946,93 @@
"evidence_tier": "test-proof"
}
]
},
{
"id": "RA35",
"summary": "Every Patrol-initiated infrastructure mutation originates as a typed action proposal and reaches execution only through the canonical action lifecycle; unsupported proposals fail closed with no raw-command fallback.",
"kind": "invariant",
"blocking_level": "repo-ready",
"proof_type": "automated",
"lane_ids": [
"L6",
"L20"
],
"subsystem_ids": [
"ai-runtime",
"api-contracts"
],
"release_gate_ids": [],
"proof_commands": [
{
"id": "ra35-typed-proposal-channel",
"run": [
"go",
"test",
"./internal/ai/tools",
"-run",
"TestConcurrentValidProposalsLatchAmbiguityWithNoProposal|TestProposalReplaySemanticsByInvocationID|TestFailedAttemptsWithoutSuccessAreATypedError|TestSensitiveProposalParamsRejectedWithoutEcho|TestProposeActionIsInvestigationProfileOnly|TestCapturedProposalIsImmuneToCallerMutation|TestProposalCapabilityMatchingIsExactLikePlanning|TestPatrolDetectionProfileEnforcesAllowlistedPulseState|TestPatrolInvestigationProfileIsStructurallyReadOnly",
"-count=1"
]
},
{
"id": "ra35-typed-proposal-contract",
"run": [
"go",
"test",
"./pkg/aicontracts",
"-run",
"TestOrchestratorChatServiceIsInvestigationOnly|TestOrchestratorDepsHasNoCommandOrAutonomyDeps|TestOrchestratorActionBrokerIsProposeOnly|TestActionCapabilityParamInfoCarriesPattern|TestInvestigationResultCarriesStructuredProposal",
"-count=1"
]
}
],
"evidence": [
{
"repo": "pulse",
"path": "internal/ai/chat/service_investigation.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/tools/execution_profile.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/tools/proposal_capture.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/tools/proposal_capture_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/ai/tools/tools_propose.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "internal/api/patrol_action_broker.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "pkg/aicontracts/action_broker.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "pkg/aicontracts/contracts_test.go",
"kind": "file"
},
{
"repo": "pulse",
"path": "pkg/aicontracts/orchestrator_deps.go",
"kind": "file"
}
]
}
],
"evidence_reference_policy": {
@@ -5503,7 +5590,7 @@
"status": "partial",
"completion": {
"state": "bounded-residual",
"summary": "Action governance now has a first-class governed floor: tool capability declarations include typed agent-operable infrastructure onboarding over the canonical node lifecycle and LAN-discovery APIs, approval-backed action plans, plan-level preflight and dry-run posture, fail-closed action-audit normalization, bounded lifecycle events, API-visible action audit preflight, and a resource-scoped action-history surface are owned by the shared AI, API, security, Patrol, and unified-resource contracts. Broader enterprise policy execution, richer dry-run providers, multi-actor approvals, full onboarding import-plan approvals, continuous discovery-to-finding reconciliation, and deeper safe-execution contracts remain named post-RC hardening tracks.",
"summary": "Action governance has a first-class governed floor AND the typed action lifecycle is now the only autonomous route for Patrol: investigations run under a core-owned, non-interactive, read-only execution profile and can only emit a side-effect-free typed action proposal (patrol_propose_action), which the shared action lifecycle plans, approves, and executes with plan hashing, remediation locks, plan-drift revalidation, and read-after-write verification. The enterprise orchestrator consumes a structured investigation result and submits through the required action broker; PROPOSED_FIX parsing, direct command execution, command-shaped approval creation and re-arming, and the core-generated remediation-plan path are all removed, and command text in investigation prose is inert. Registry-owned invocation descriptors classify every tool by workflow kind and mutation target and enforce control level and profile at both projection and execution. Remaining post-RC hardening: policy-scoped autonomy presets (per tenant/resource/action/window), host-update and storage-pressure workflows as the second and third complete verticals, richer dry-run providers, multi-actor approvals, and a real-lab detect-plan-execute-verify-rollback journey proof.",
"tracking": [
{
"kind": "lane-followup",
@@ -8383,6 +8470,16 @@
"L9",
"L12"
]
},
{
"id": "mobile-candidate-refresh-in-release-workflow",
"summary": "Governed release workflow must account for Pulse Mobile candidate freshness when release work changes mobile-facing relay, auth, approval, notification, download, or customer-access surfaces: refresh the TestFlight and Play internal-testing candidate, or explicitly record why the existing mobile candidate remains valid. This does not convert the direct-link beta into public App Store or Play rollout; public mobile release remains gated by current mobile proof and the non-public rollout policy.",
"kind": "release-policy",
"decided_at": "2026-07-07",
"subsystem_ids": [],
"lane_ids": [
"L5"
]
}
]
}
@@ -352,7 +352,11 @@ Patrol proposal broker (`internal/api/patrol_action_broker.go`) rides that
same API-owned lifecycle; agent lifecycle surfaces must not consume
Patrol-origin action audits as an agent command grant or lifecycle
execution shortcut, and must not subscribe lifecycle side effects to the
API-owned org-scoped action-transition hook. Assistant
API-owned org-scoped action-transition hook. The investigation
orchestrator adapter wired here exposes only investigation-specific
execution and listing with no autonomy or command-execution surface, so
agent lifecycle surfaces gain no command-dispatch path through Patrol
investigations. Assistant
handoffs that recover a live Patrol approval by finding ID are still AI/runtime
review context only; agent lifecycle surfaces must not treat that recovered
approval reference as an agent command grant or host-execution shortcut.
@@ -4566,20 +4566,48 @@ deliberately omits org ID, requestedBy, autonomy, risk, approval-policy,
destructive, command, and target-host fields, and has no decide or execute
methods, so enterprise code can neither claim authorization nor dispatch;
authorization always derives from the capability's declared policy on the
core lifecycle. `OrchestratorDeps.ActionBroker` carries the seam;
`CmdExecutor` and `ApprovalStore` on the same struct are migration-only
legacy side doors that must not gain new callers and are scheduled for
removal with the orchestrator migration. Investigations reference their
canonical action through the additive `Action *ActionReference` fields on
core lifecycle. `OrchestratorDeps.ActionBroker` carries the seam and is
now REQUIRED: the enterprise factory disables the orchestrator when it is
absent rather than falling back. The command-execution side doors are
gone. `OrchestratorChatService` exposes only investigation-specific
execution and listing (`ExecuteInvestigationStream` returning a
structured `OrchestratorInvestigationResult`, and
`ListInvestigationTools`); it has no generic `ExecuteStream`, no
`SetAutonomousMode`, no `ListAvailableTools`, and the request type carries
no autonomy field, so enterprise code can neither select a profile nor
grant execution authority. `OrchestratorCommandExecutor`,
`OrchestratorApprovalStore`, and the autonomy/fix-verifier/license
dependency interfaces are removed along with their pulse-side adapters
(`orchestratorChatAdapter.ExecuteStream`/`ExecuteCommand`/
`SetAutonomousMode`, `orchestratorApprovalAdapter`,
`autonomyLevelProviderAdapter`, `patrolFixVerifierAdapter`,
`licenseCheckerForOrchestrator`). The enterprise investigation
orchestrator no longer parses `PROPOSED_FIX`, runs guardrails, executes
commands, or queues command-shaped approvals: it consumes the structured
investigation result and, only for a non-nil proposal from a completely
successful run, submits through the broker and persists the resulting
`ActionReference`. Command text in investigation prose is inert
historical text with no execution, approval, or remediation-plan pathway
(`classifyInvestigationOutcome` reads only conclusion markers). The core
`PatrolService.generateRemediationPlanFromInvestigation` path that copied
`Fix.Commands` into an executable remediation plan is deleted, so no
second command-backed remediation artifact remains. The legacy
`aiautofix` command-approval endpoints fail closed
(`command_fix_retired`), never approving, re-arming, or executing a
persisted command-shaped fix. Investigations reference their canonical
action through the additive `Action *ActionReference` fields on
`InvestigationSession` and `InvestigationRecord`; the command-shaped
`ProposedFix`/`ApprovalID` fields are migration-only narrative and must
never be populated by new investigations nor re-armed into executable
approvals. Proposal parameters live in the canonical action audit, not
duplicated into investigation stores. Proofs:
`ProposedFix`/`ApprovalID` fields remain readable for old persisted
records only and are never populated by new investigations nor re-armed
into executable approvals. Proposal parameters live in the canonical
action audit, not duplicated into investigation stores. Proofs:
`TestOrchestratorActionBrokerIsProposeOnly`,
`TestOrchestratorChatServiceIsInvestigationOnly`,
`TestOrchestratorDepsHasNoCommandOrAutonomyDeps`,
`TestActionProposalWireShapeIsTypedAndCommandFree`, and
`TestActionReferenceIsAdditiveOnInvestigationShapes` in
`pkg/aicontracts/contracts_test.go`.
`pkg/aicontracts/contracts_test.go`, plus the enterprise
`TestProposedFixProseIsInertHistoricalText` negative regression.
Tool-call safety classification is registry-owned through the canonical
invocation descriptors in `internal/agentcapabilities/invocation.go`:
every registered Pulse tool has a static or discriminator-based
@@ -1877,7 +1877,18 @@ a new API state machine, queue contract, or verification-accounting field.
validates through the planner's exported canonical rules
(`actionplanner.FindCapability` / `actionplanner.ValidateParams`),
so a proposal that validates is exactly a proposal the planner will
accept.
accept; the broker's `ActionCapabilityParamInfo` projection carries
`Pattern` alongside enum and sensitivity so that validation parity
survives the cross-repo boundary. The Patrol proposal broker and the
per-org proposal capability catalog are wired from the router onto the
AI settings handler (`SetActionBrokerFactory` /
`SetProposalCatalogFactory`), and the investigation adapter
(`orchestratorChatAdapter`) exposes only
`ExecuteInvestigationStream` (returning the structured investigation
result with its typed proposal errors) and `ListInvestigationTools`.
The retired command-execution, autonomy, and command-shaped approval
adapters are removed, and the legacy `aiautofix` command-approval
endpoints fail closed with `command_fix_retired`.
Executor-owned live readiness is part of planning, not a UI precheck:
after planner validation and before audit persistence, the lifecycle
service must ask
@@ -352,6 +352,11 @@ regression protection.
while per-action resource refresh, policy validation, agent command dispatch,
polling verification, and audit completion must occur inside the route-local
execute handler path.
The Patrol action-broker and proposal-catalog factories are wired the same
bounded way: `internal/api/router.go` installs the per-org factory closures
on the AI settings handler once at startup, and each broker or catalog is
constructed lazily per investigation run rather than eagerly per request, so
the wiring adds no fan-out to the protected hot path.
Proxmox VM/LXC lifecycle execution follows that same routed-executor budget:
router setup may register the Proxmox executor alongside Docker / Podman,
but it must not resolve guests, probe node agents, call `qm` / `pct`, poll
@@ -321,7 +321,12 @@ the `white_label` branding entitlement.
`RequireAuth` and scope checks, resource-policy redaction pass, or
read-only Agent-context boundary. Router glue may connect providers, but
it must not become an alternate command path, raw provider-command path,
config path, environment path, or secret-bearing metadata path.
config path, environment path, or secret-bearing metadata path. The Patrol
action-broker and proposal-catalog factory glue wired here is bound by the
same rule: it may connect the investigation orchestrator to the tenant-bound
action lifecycle, but it exposes only typed-proposal capture and gives the
orchestrator no autonomy control, command execution, or command-shaped
approval path.
Router glue may also pass monitor-owned source freshness thresholds into
unified-resource adapters, but those thresholds are operational cadence
metadata only. They must not disclose credentials, command output, raw
@@ -119,7 +119,12 @@ metadata) that is ai-runtime config-surface, not storage or recovery
state; a manual scoped Patrol check routed through this handler is
investigate-only as well (it may analyze storage resources but invokes no
backup, restore, SMART, or recovery operation, and carries resource identity
only). Starter counts, contextual Assistant/external-agent collaboration
only). The Patrol action-broker and proposal-catalog factories wired from
`internal/api/router.go` onto that AI settings handler are likewise
ai-runtime/api-owned: a storage-targeted investigation may only emit a typed
action proposal onto the canonical action lifecycle, never a storage or
recovery operation, so this wiring creates no storage/recovery execution
surface. Starter counts, contextual Assistant/external-agent collaboration
counts, Patrol control completed-loop or resolved-loop outcome evidence,
`patrolControlValueState`, legacy `patrolAutonomy*` compatibility aliases, and
the operator-readable `progressLabel` on that
+4 -131
View File
@@ -14,7 +14,6 @@ import (
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/baseline"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/safety"
"github.com/rcourtman/pulse-go-rewrite/internal/ai/tools"
"github.com/rcourtman/pulse-go-rewrite/internal/relay"
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
@@ -193,133 +192,6 @@ func (p *PatrolService) clearBlockedReason() {
p.mu.Unlock()
}
// generateRemediationPlanFromInvestigation persists a remediation plan artifact when
// an investigation proposes a concrete fix command. This is intentionally separate
// from the "approval" execution pipeline; it's a durable summary users can act on
// later (often via Pulse Assistant).
func (p *PatrolService) generateRemediationPlanFromInvestigation(findingID string) {
p.mu.RLock()
engine := p.remediationEngine
orchestrator := p.investigationOrchestrator
p.mu.RUnlock()
if engine == nil || orchestrator == nil || p.findings == nil || findingID == "" {
return
}
finding := p.findings.Get(findingID)
if finding == nil {
return
}
inv := orchestrator.GetInvestigationByFinding(findingID)
if inv == nil || inv.ProposedFix == nil || len(inv.ProposedFix.Commands) == 0 {
return
}
fix := inv.ProposedFix
targetHost := strings.TrimSpace(fix.TargetHost)
if targetHost == "" {
targetHost = "local"
}
// Map investigation risk strings into remediation risk levels.
riskLevel := aicontracts.RiskMedium
switch strings.ToLower(strings.TrimSpace(fix.RiskLevel)) {
case "low":
riskLevel = aicontracts.RiskLow
case "medium":
riskLevel = aicontracts.RiskMedium
case "high":
riskLevel = aicontracts.RiskHigh
case "critical":
riskLevel = aicontracts.RiskHigh
}
steps := make([]aicontracts.RemediationStep, 0, 2+len(fix.Commands))
steps = append(steps, aicontracts.RemediationStep{
Order: 1,
Description: "Review the finding context and confirm the proposed fix is appropriate",
})
blockedCount := 0
order := 2
for _, raw := range fix.Commands {
cmd := strings.TrimSpace(raw)
if cmd == "" {
continue
}
stepCommand := cmd
stepDesc := fmt.Sprintf("Run the proposed fix on %s", targetHost)
if safety.IsBlockedCommand(cmd) {
// Don't store blocked commands in the remediation engine; keep the plan as an
// artifact for users to review and apply manually (typically via Assistant).
stepCommand = ""
stepDesc = fmt.Sprintf("Blocked command proposed by investigation (review and apply manually): %s", cmd)
blockedCount++
}
steps = append(steps, aicontracts.RemediationStep{
Order: order,
Description: stepDesc,
Command: stepCommand,
Target: targetHost,
})
order++
}
steps = append(steps, aicontracts.RemediationStep{
Order: order,
Description: "Verify the issue is resolved (re-check metrics/logs, confirm service health)",
})
description := strings.TrimSpace(fix.Rationale)
if description == "" {
description = strings.TrimSpace(inv.Summary)
}
if description == "" {
description = finding.Description
}
plan := &aicontracts.RemediationPlan{
FindingID: finding.ID,
ResourceID: finding.ResourceID,
Title: fmt.Sprintf("Investigation Fix: %s", finding.Title),
Description: description,
Category: aicontracts.CategoryGuided,
RiskLevel: riskLevel,
Steps: steps,
Rationale: fix.Description,
}
// Patrol findings are often reviewed hours/days later; keep investigation-derived
// plans around longer than the default ephemeral remediation TTL.
expires := time.Now().Add(7 * 24 * time.Hour)
plan.ExpiresAt = &expires
if blockedCount > 0 {
plan.Warnings = append(plan.Warnings, "Investigation suggested one or more commands that are blocked by safety policy. Review carefully and apply manually (prefer Pulse Assistant).")
}
if err := engine.CreatePlan(plan); err != nil {
// As a fallback, keep the plan as purely informational so it can still be
// surfaced to the user without enabling remediation engine execution.
for i := range plan.Steps {
if plan.Steps[i].Command == "" {
continue
}
plan.Steps[i].Description = fmt.Sprintf("%s: %s", plan.Steps[i].Description, plan.Steps[i].Command)
plan.Steps[i].Command = ""
}
plan.RiskLevel = aicontracts.RiskMedium
plan.Warnings = append(plan.Warnings, fmt.Sprintf("Failed to store command steps for automated remediation: %v", err))
if createErr := engine.CreatePlan(plan); createErr != nil {
log.Warn().Err(createErr).Str("findingID", finding.ID).Msg("failed to create fallback remediation plan")
}
}
}
// GetFindingsForResource returns active findings for a specific resource
func (p *PatrolService) GetFindingsForResource(resourceID string) []*Finding {
findings := p.findings.GetByResource(resourceID)
@@ -1277,9 +1149,10 @@ func (p *PatrolService) MaybeInvestigateFinding(f *Finding) {
}
}
// Investigation finished successfully. If it produced a proposed fix, persist a
// remediation plan artifact so the user can review and execute later.
p.generateRemediationPlanFromInvestigation(f.ID)
// Typed remediation now flows exclusively through the action
// proposal channel: the investigation's ActionReference points at
// the canonical action audit, so no command-shaped remediation
// plan artifact is generated from investigation prose.
}()
log.Info().
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
@@ -1348,3 +1349,23 @@ func TestPatrolFindingCreatorAdapter_ResolveFinding_FallsThroughWhenNoVerifierAv
t.Fatalf("expected finding to be resolved, got %+v", stored)
}
}
// TestGenerateRemediationPlanFromInvestigationIsDeleted pins the sixth
// side door closed: the path that copied Fix.Commands into an executable
// enterprise remediation plan must not return. Typed remediation flows
// only through the action proposal channel.
func TestGenerateRemediationPlanFromInvestigationIsDeleted(t *testing.T) {
source, err := os.ReadFile("patrol_findings.go")
if err != nil {
t.Fatalf("read patrol_findings.go: %v", err)
}
src := string(source)
for _, forbidden := range []string{
"generateRemediationPlanFromInvestigation",
"inv.ProposedFix.Commands",
} {
if strings.Contains(src, forbidden) {
t.Fatalf("command-backed remediation path must stay deleted; found %q", forbidden)
}
}
}
+130 -128
View File
@@ -63,6 +63,8 @@ type AISettingsHandler struct {
readState unifiedresources.ReadState
unifiedResourceProvider ai.UnifiedResourceProvider
resourceStoreProvider func(orgID string) (unifiedresources.ResourceStore, error)
actionBrokerFactory func(orgID string) aicontracts.OrchestratorActionBroker
proposalCatalogFactory func(orgID string) tools.ProposalCatalog
metadataProvider ai.MetadataProvider
patrolThresholdProvider ai.ThresholdProvider
metricsHistoryProvider ai.MetricsHistoryProvider
@@ -133,6 +135,57 @@ func (h *AISettingsHandler) SetResourceStoreProvider(provider func(orgID string)
h.resourceStoreProvider = provider
}
// SetActionBrokerFactory installs the per-org typed action proposal broker
// used by the investigation orchestrator. Core-owned: it binds the tenant
// and the fixed Patrol actor, so enterprise code can only propose typed
// capabilities, never dispatch commands or claim authority.
func (h *AISettingsHandler) SetActionBrokerFactory(factory func(orgID string) aicontracts.OrchestratorActionBroker) {
if h == nil {
return
}
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.actionBrokerFactory = factory
}
// SetProposalCatalogFactory installs the per-org capability catalog used
// for proposal validation, resolved from the tenant-bound action
// lifecycle service so acceptance and planning share one contract.
func (h *AISettingsHandler) SetProposalCatalogFactory(factory func(orgID string) tools.ProposalCatalog) {
if h == nil {
return
}
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.proposalCatalogFactory = factory
}
func (h *AISettingsHandler) actionBrokerFor(orgID string) aicontracts.OrchestratorActionBroker {
if h == nil {
return nil
}
h.stateMu.RLock()
factory := h.actionBrokerFactory
h.stateMu.RUnlock()
if factory == nil {
return nil
}
return factory(orgID)
}
func (h *AISettingsHandler) proposalCatalogFor(orgID string) tools.ProposalCatalog {
if h == nil {
return nil
}
h.stateMu.RLock()
factory := h.proposalCatalogFactory
h.stateMu.RUnlock()
if factory == nil {
return nil
}
return factory(orgID)
}
func (h *AISettingsHandler) stateRefs() (
*config.MultiTenantPersistence,
*monitoring.MultiTenantMonitor,
@@ -1954,7 +2007,10 @@ func (h *AISettingsHandler) setupInvestigationOrchestrator(orgID string, svc *ai
})
// Build local adapters that implement aicontracts.Orchestrator* interfaces
chatAdapter := &orchestratorChatAdapter{svc: chatService}
chatAdapter := &orchestratorChatAdapter{
svc: chatService,
catalog: h.proposalCatalogFor(orgID),
}
// Create findings store adapter
findingsStore := patrol.GetFindings()
@@ -1964,16 +2020,6 @@ func (h *AISettingsHandler) setupInvestigationOrchestrator(orgID string, svc *ai
}
findingsAdapter := &orchestratorFindingsAdapter{store: &findingsStoreWrapper{store: findingsStore}}
// Create approval adapter from the global approval store
var approvalAdapter aicontracts.OrchestratorApprovalStore
if approvalStoreInst := approval.GetStore(); approvalStoreInst != nil {
approvalAdapter = &orchestratorApprovalAdapter{
store: approvalStoreInst,
orgID: approval.NormalizeOrgID(orgID),
actionAuditStore: chatService.GetActionAuditStore(),
}
}
// Get config for investigation settings
cfg := svc.GetConfig()
invConfig := aicontracts.DefaultInvestigationConfig()
@@ -2008,18 +2054,16 @@ func (h *AISettingsHandler) setupInvestigationOrchestrator(orgID string, svc *ai
infraContext = knowledgeStore
}
// Build deps struct and call factory
// Build deps struct and call factory. The typed action broker is
// REQUIRED: without it the factory disables the orchestrator - there
// is no command-execution fallback.
deps := aicontracts.OrchestratorDeps{
ChatService: chatAdapter,
CmdExecutor: chatAdapter, // ChatServiceAdapter implements both interfaces
Store: store,
FindingsStore: findingsAdapter,
ApprovalStore: approvalAdapter,
ActionBroker: h.actionBrokerFor(approval.NormalizeOrgID(orgID)),
Config: invConfig,
InfraContext: infraContext,
Autonomy: &autonomyLevelProviderAdapter{svc: svc},
FixVerifier: &patrolFixVerifierAdapter{patrol: patrol},
License: &licenseCheckerForOrchestrator{svc: svc},
Metrics: &patrolMetricsCallbackAdapter{},
}
@@ -2041,9 +2085,16 @@ func (h *AISettingsHandler) setupInvestigationOrchestrator(orgID string, svc *ai
// ---------------------------------------------------------------------------
// orchestratorChatAdapter wraps *chat.Service to implement
// aicontracts.OrchestratorChatService and OrchestratorCommandExecutor.
// aicontracts.OrchestratorChatService. It exposes only the
// investigation-specific execution/listing surface: there is no generic
// chat execution, no autonomy control, and no command execution here -
// the typed proposal channel is the only route from an investigation to
// an infrastructure mutation.
type orchestratorChatAdapter struct {
svc *chat.Service
// catalog resolves advertised resource capabilities for proposal
// validation, from the tenant-bound action lifecycle service.
catalog tools.ProposalCatalog
}
func (a *orchestratorChatAdapter) CreateSession(ctx context.Context) (*aicontracts.OrchestratorChatSession, error) {
@@ -2054,25 +2105,73 @@ func (a *orchestratorChatAdapter) CreateSession(ctx context.Context) (*aicontrac
return &aicontracts.OrchestratorChatSession{ID: session.ID}, nil
}
func (a *orchestratorChatAdapter) ExecuteStream(ctx context.Context, req aicontracts.OrchestratorExecuteRequest, callback aicontracts.OrchestratorStreamCallback) error {
func (a *orchestratorChatAdapter) ExecuteInvestigationStream(ctx context.Context, req aicontracts.OrchestratorInvestigationRequest, callback aicontracts.OrchestratorStreamCallback) (*aicontracts.OrchestratorInvestigationResult, error) {
if a.svc == nil {
return fmt.Errorf("chat service is nil")
return nil, fmt.Errorf("chat service is nil")
}
if !a.svc.IsRunning() {
return fmt.Errorf("chat service is not running")
return nil, fmt.Errorf("chat service is not running")
}
chatReq := chat.ExecuteRequest{
Prompt: req.Prompt,
SessionID: req.SessionID,
MaxTurns: req.MaxTurns,
AutonomousMode: req.AutonomousMode,
}
return a.svc.ExecuteStream(ctx, chatReq, func(event chat.StreamEvent) {
runResult, err := a.svc.ExecuteInvestigationStream(ctx, chat.InvestigationRunRequest{
SessionID: req.SessionID,
Prompt: req.Prompt,
SystemPrompt: req.SystemPrompt,
MaxTurns: req.MaxTurns,
ExecutionID: req.ExecutionID,
Identity: tools.ProposalIdentity{
ProposalID: req.ProposalID,
FindingID: req.FindingID,
InvestigationID: req.InvestigationID,
EvidenceIDs: req.EvidenceIDs,
},
Catalog: a.catalog,
}, func(event chat.StreamEvent) {
callback(aicontracts.OrchestratorStreamEvent{
Type: event.Type,
Data: event.Data,
})
})
if runResult == nil {
return nil, mapInvestigationProposalError(err)
}
result := &aicontracts.OrchestratorInvestigationResult{
Content: runResult.Content,
FailedProposalAttempts: runResult.FailedProposalAttempts,
InputTokens: runResult.InputTokens,
OutputTokens: runResult.OutputTokens,
}
if runResult.Proposal != nil {
captured := runResult.Proposal
result.Proposal = &aicontracts.ActionProposal{
ProposalID: captured.Identity.ProposalID,
FindingID: captured.Identity.FindingID,
InvestigationID: captured.Identity.InvestigationID,
ResourceID: captured.ResourceID,
CapabilityName: captured.CapabilityName,
Params: captured.Params,
Reason: captured.Reason,
EvidenceIDs: captured.Identity.EvidenceIDs,
}
}
return result, mapInvestigationProposalError(err)
}
// mapInvestigationProposalError projects the core proposal-channel errors
// onto the public contract sentinels so enterprise outcome mapping can
// key on errors.Is without importing internal packages.
func mapInvestigationProposalError(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, tools.ErrProposalAmbiguous):
return aicontracts.ErrInvestigationProposalAmbiguous
case errors.Is(err, tools.ErrProposalIntegrity):
return aicontracts.ErrInvestigationProposalIntegrity
case errors.Is(err, tools.ErrProposalAttemptsFailed):
return aicontracts.ErrInvestigationProposalAttemptsFailed
default:
return err
}
}
//nolint:dupl // mirrors chatServiceAdapter.GetMessages: same source messages mapped onto a deliberately separate output contract that may diverge
@@ -2106,24 +2205,12 @@ func (a *orchestratorChatAdapter) DeleteSession(ctx context.Context, sessionID s
return a.svc.DeleteSession(ctx, sessionID)
}
func (a *orchestratorChatAdapter) ListAvailableTools(ctx context.Context, prompt string) []string {
func (a *orchestratorChatAdapter) ListInvestigationTools(ctx context.Context) []string {
_ = ctx
if a.svc == nil {
return nil
}
return a.svc.ListAvailableTools(ctx, prompt)
}
func (a *orchestratorChatAdapter) SetAutonomousMode(enabled bool) {
if a.svc != nil {
a.svc.SetAutonomousMode(enabled)
}
}
func (a *orchestratorChatAdapter) ExecuteCommand(ctx context.Context, command, targetHost string) (string, int, error) {
if a.svc == nil {
return "", -1, fmt.Errorf("chat service not available")
}
return a.svc.ExecuteCommand(ctx, command, targetHost)
return a.svc.ListInvestigationTools()
}
// orchestratorFindingsAdapter wraps findingsStoreWrapper to implement
@@ -2173,59 +2260,6 @@ func (a *orchestratorFindingsAdapter) Update(f *aicontracts.Finding) bool {
)
}
// orchestratorApprovalAdapter wraps *approval.Store to implement
// aicontracts.OrchestratorApprovalStore.
type orchestratorApprovalAdapter struct {
store *approval.Store
orgID string
actionAuditStore unifiedresources.ResourceStore
}
func (a *orchestratorApprovalAdapter) Create(appr *aicontracts.OrchestratorApproval) error {
if a.store == nil {
return nil
}
riskLevel := approval.RiskLow
switch appr.RiskLevel {
case "low":
riskLevel = approval.RiskLow
case "medium":
riskLevel = approval.RiskMedium
case "high", "critical":
riskLevel = approval.RiskHigh
}
req := &approval.ApprovalRequest{
OrgID: a.orgID,
ID: appr.ID,
ToolID: "investigation_fix",
Command: appr.Command,
TargetType: "investigation",
TargetID: appr.FindingID,
TargetName: strings.TrimSpace(appr.TargetHost),
Context: "Automated fix from patrol investigation: " + appr.Description,
RiskLevel: riskLevel,
}
if req.TargetName == "" {
req.TargetName = appr.Description
}
tools.AttachApprovalActionPlan(req, time.Now().UTC())
if err := a.store.CreateApproval(req); err != nil {
return err
}
tools.RecordPendingApprovalAction(a.actionAuditStore, req)
return nil
}
// patrolFixVerifierAdapter wraps *ai.PatrolService to implement
// aicontracts.OrchestratorFixVerifier.
type patrolFixVerifierAdapter struct {
patrol *ai.PatrolService
}
func (v *patrolFixVerifierAdapter) VerifyFixResolved(ctx context.Context, finding *aicontracts.Finding) (bool, error) {
return v.patrol.VerifyFixResolved(ctx, finding.ResourceID, finding.ResourceType, finding.Key, finding.ID)
}
// patrolMetricsCallbackAdapter implements aicontracts.OrchestratorMetricsCallback
// by delegating to the global PatrolMetrics singleton.
type patrolMetricsCallbackAdapter struct{}
@@ -2238,15 +2272,6 @@ func (c *patrolMetricsCallbackAdapter) RecordFixVerification(result string) {
ai.GetPatrolMetrics().RecordFixVerification(result)
}
// licenseCheckerForOrchestrator adapts *ai.Service to aicontracts.OrchestratorLicenseChecker
type licenseCheckerForOrchestrator struct {
svc *ai.Service
}
func (l *licenseCheckerForOrchestrator) HasFeature(feature string) bool {
return l.svc.HasLicenseFeature(feature)
}
// findingsStoreWrapper wraps *ai.FindingsStore to implement aicontracts.OrchestratorAIFindingsStore
type findingsStoreWrapper struct {
store *ai.FindingsStore
@@ -2270,29 +2295,6 @@ func (w *findingsStoreWrapper) UpdateInvestigation(id, sessionID, status, outcom
return w.store.UpdateInvestigation(id, sessionID, status, outcome, lastInvestigatedAt, attempts)
}
// autonomyLevelProviderAdapter provides current autonomy level from config for re-checking before fix execution
type autonomyLevelProviderAdapter struct {
svc *ai.Service
}
func (a *autonomyLevelProviderAdapter) GetCurrentAutonomyLevel() string {
if a.svc == nil {
return config.PatrolAutonomyMonitor
}
return a.svc.GetEffectivePatrolAutonomyLevel()
}
func (a *autonomyLevelProviderAdapter) IsFullModeUnlocked() bool {
if a.svc == nil {
return false
}
cfg := a.svc.GetConfig()
if cfg == nil {
return false
}
return cfg.PatrolFullModeUnlocked
}
// AISettingsResponse is returned by GET /api/settings/ai
// API keys are masked for security
type AISettingsResponse struct {
-51
View File
@@ -515,54 +515,3 @@ func TestAISettingsHandler_GetAIService_NonDefaultWithTenantMonitorWithoutPersis
t.Fatal("expected fail-closed tenant service to be disabled")
}
}
func TestAutonomyLevelProviderAdapter(t *testing.T) {
adapter := &autonomyLevelProviderAdapter{}
if got := adapter.GetCurrentAutonomyLevel(); got != config.PatrolAutonomyMonitor {
t.Fatalf("expected default autonomy %q, got %q", config.PatrolAutonomyMonitor, got)
}
if adapter.IsFullModeUnlocked() {
t.Fatalf("expected full mode locked by default")
}
tmp := t.TempDir()
persistence := config.NewConfigPersistence(tmp)
aiCfg := config.NewDefaultAIConfig()
aiCfg.PatrolAutonomyLevel = config.PatrolAutonomyAssisted
aiCfg.PatrolFullModeUnlocked = true
if err := persistence.SaveAIConfig(*aiCfg); err != nil {
t.Fatalf("SaveAIConfig: %v", err)
}
svc := ai.NewService(persistence, nil)
if err := svc.LoadConfig(); err != nil {
t.Fatalf("LoadConfig: %v", err)
}
adapter = &autonomyLevelProviderAdapter{svc: svc}
if got := adapter.GetCurrentAutonomyLevel(); got != config.PatrolAutonomyAssisted {
t.Fatalf("expected autonomy %q, got %q", config.PatrolAutonomyAssisted, got)
}
if !adapter.IsFullModeUnlocked() {
t.Fatalf("expected full mode unlocked")
}
}
func TestLicenseCheckerForOrchestrator(t *testing.T) {
tmp := t.TempDir()
persistence := config.NewConfigPersistence(tmp)
svc := ai.NewService(persistence, nil)
if err := svc.LoadConfig(); err != nil {
t.Fatalf("LoadConfig: %v", err)
}
checker := &licenseCheckerForOrchestrator{svc: svc}
if !checker.HasFeature("pro") {
t.Fatalf("expected default license to allow feature")
}
svc.SetLicenseChecker(stubLicenseChecker{allow: false})
if checker.HasFeature("pro") {
t.Fatalf("expected license checker to deny feature")
}
}
-54
View File
@@ -3278,60 +3278,6 @@ func TestAISettingsHandler_Approvals(t *testing.T) {
})
}
func TestOrchestratorApprovalAdapterRecordsPendingActionAudit(t *testing.T) {
approvalStore, err := approval.NewStore(approval.StoreConfig{
DataDir: t.TempDir(),
DisablePersistence: true,
})
require.NoError(t, err)
actionStore := unifiedresources.NewMemoryStore()
adapter := &orchestratorApprovalAdapter{
store: approvalStore,
orgID: "tenant-1",
actionAuditStore: actionStore,
}
err = adapter.Create(&aicontracts.OrchestratorApproval{
ID: "approval-investigation-1",
FindingID: "finding-123",
Command: "systemctl restart nginx",
TargetHost: "web-1",
Description: "Restart nginx after Patrol investigation",
RiskLevel: "high",
})
require.NoError(t, err)
req, ok := approvalStore.GetApproval("approval-investigation-1")
require.True(t, ok, "expected approval to be persisted")
require.NotNil(t, req.Plan, "expected Patrol investigation approval to carry a governed action plan")
require.NotEmpty(t, req.Plan.ActionID)
require.Equal(t, req.ID, req.Plan.RequestID)
require.True(t, req.Plan.RequiresApproval)
require.Equal(t, unifiedresources.ApprovalAdmin, req.Plan.ApprovalPolicy)
require.NotNil(t, req.Preflight)
require.NotNil(t, req.ContextConfidence)
audit, ok, err := actionStore.GetActionAudit(req.Plan.ActionID)
require.NoError(t, err)
require.True(t, ok, "expected queued Patrol fix approval to seed action audit")
require.Equal(t, unifiedresources.ActionStatePending, audit.State)
require.Equal(t, req.ID, audit.Request.RequestID)
require.Equal(t, "investigation:finding-123", audit.Request.ResourceID)
require.Equal(t, "pulse_patrol", audit.Request.RequestedBy)
require.NotNil(t, audit.Plan.Preflight)
events, err := actionStore.GetActionLifecycleEvents(req.Plan.ActionID, time.Time{}, 10)
require.NoError(t, err)
states := map[unifiedresources.ActionState]bool{}
for _, event := range events {
states[event.State] = true
require.Equal(t, "pulse_patrol", event.Actor)
}
require.True(t, states[unifiedresources.ActionStatePlanned], "missing planned lifecycle event: %#v", events)
require.True(t, states[unifiedresources.ActionStatePending], "missing pending lifecycle event: %#v", events)
}
func TestAISettingsHandler_Approvals_RejectCrossOrgAccess(t *testing.T) {
tmp := t.TempDir()
cfg := &config.Config{DataPath: tmp}
+20 -4
View File
@@ -607,13 +607,29 @@ func TestContract_AssistantFindingContextUsesModelOnlyHandoff(t *testing.T) {
}
settingsHandlerText := string(settingsHandlerSource)
// Patrol remediation now enters as a typed action proposal through the
// required action broker; the investigation adapter carries no
// command execution, no autonomy, and no command-shaped approval
// seeding.
for _, required := range []string{
"actionAuditStore: chatService.GetActionAuditStore()",
"tools.AttachApprovalActionPlan(req, time.Now().UTC())",
"tools.RecordPendingApprovalAction(a.actionAuditStore, req)",
"ActionBroker: h.actionBrokerFor(approval.NormalizeOrgID(orgID))",
"catalog: h.proposalCatalogFor(orgID)",
"func (a *orchestratorChatAdapter) ExecuteInvestigationStream(",
} {
if !strings.Contains(settingsHandlerText, required) {
t.Fatalf("ai_handlers.go must preserve Patrol queued-fix action-audit seeding: missing %q", required)
t.Fatalf("ai_handlers.go must wire the typed investigation proposal channel: missing %q", required)
}
}
for _, forbidden := range []string{
"func (a *orchestratorChatAdapter) ExecuteStream(",
"func (a *orchestratorChatAdapter) SetAutonomousMode(",
"func (a *orchestratorChatAdapter) ExecuteCommand(",
"orchestratorApprovalAdapter",
"autonomyLevelProviderAdapter",
"patrolFixVerifierAdapter",
} {
if strings.Contains(settingsHandlerText, forbidden) {
t.Fatalf("ai_handlers.go must not retain a retired command/autonomy adapter: found %q", forbidden)
}
}
+1
View File
@@ -65,6 +65,7 @@ func (b *patrolActionBroker) Capabilities(ctx context.Context, resourceID string
Type: param.Type,
Required: param.Required,
Enum: append([]string(nil), param.Enum...),
Pattern: param.Pattern,
Description: param.Description,
Sensitive: param.IsSensitive,
})
+9
View File
@@ -658,6 +658,15 @@ func (r *Router) setupRoutes() {
r.aiSettingsHandler = NewAISettingsHandler(r.multiTenant, r.mtMonitor, r.agentExecServer)
if r.resourceHandlers != nil {
r.aiSettingsHandler.SetResourceStoreProvider(r.resourceHandlers.getStore)
resourceHandlers := r.resourceHandlers
r.aiSettingsHandler.SetActionBrokerFactory(func(orgID string) aicontracts.OrchestratorActionBroker {
return NewPatrolActionBroker(orgID, resourceHandlers)
})
r.aiSettingsHandler.SetProposalCatalogFactory(func(orgID string) tools.ProposalCatalog {
return func(ctx context.Context, resourceID string) ([]unifiedresources.ResourceCapability, error) {
return resourceHandlers.ActionLifecycle().Capabilities(ctx, orgID, resourceID)
}
})
}
r.aiSettingsHandler.SetConfig(r.config)
// Inject state provider so AI has access to full infrastructure context (VMs, containers, IPs)
+1
View File
@@ -59,6 +59,7 @@ type ActionCapabilityParamInfo struct {
Type string `json:"type"`
Required bool `json:"required"`
Enum []string `json:"enum,omitempty"`
Pattern string `json:"pattern,omitempty"`
Description string `json:"description,omitempty"`
Sensitive bool `json:"sensitive,omitempty"`
}
+53
View File
@@ -249,3 +249,56 @@ func TestOrchestratorDepsExposesTypedActionBroker(t *testing.T) {
t.Fatalf("ActionBroker field type = %v, want OrchestratorActionBroker", field.Type)
}
}
func TestOrchestratorChatServiceIsInvestigationOnly(t *testing.T) {
svcType := reflect.TypeOf((*OrchestratorChatService)(nil)).Elem()
// The generic-execution and autonomy surface is gone: no method may
// carry autonomy, generic execution, or a broad tool listing.
for _, forbidden := range []string{"ExecuteStream", "SetAutonomousMode", "ListAvailableTools"} {
if _, ok := svcType.MethodByName(forbidden); ok {
t.Fatalf("OrchestratorChatService must not expose %s", forbidden)
}
}
for _, required := range []string{"ExecuteInvestigationStream", "ListInvestigationTools"} {
if _, ok := svcType.MethodByName(required); !ok {
t.Fatalf("OrchestratorChatService must expose %s", required)
}
}
// The investigation request must carry no autonomy field.
reqType := reflect.TypeOf(OrchestratorInvestigationRequest{})
for i := 0; i < reqType.NumField(); i++ {
name := strings.ToLower(reqType.Field(i).Name)
if strings.Contains(name, "autonom") {
t.Fatalf("investigation request must carry no autonomy field, found %s", reqType.Field(i).Name)
}
}
}
func TestOrchestratorDepsHasNoCommandOrAutonomyDeps(t *testing.T) {
depsType := reflect.TypeOf(OrchestratorDeps{})
for _, forbidden := range []string{"CmdExecutor", "ApprovalStore", "Autonomy", "FixVerifier", "License"} {
if _, ok := depsType.FieldByName(forbidden); ok {
t.Fatalf("OrchestratorDeps must not expose %s after the typed-lifecycle migration", forbidden)
}
}
}
func TestActionCapabilityParamInfoCarriesPattern(t *testing.T) {
// Canonical planner validation enforces Pattern; the cross-repo
// capability projection must carry it or proposal validation parity
// dies at the boundary.
if _, ok := reflect.TypeOf(ActionCapabilityParamInfo{}).FieldByName("Pattern"); !ok {
t.Fatal("ActionCapabilityParamInfo must carry Pattern")
}
}
func TestInvestigationResultCarriesStructuredProposal(t *testing.T) {
resultType := reflect.TypeOf(OrchestratorInvestigationResult{})
field, ok := resultType.FieldByName("Proposal")
if !ok {
t.Fatal("investigation result must carry a structured Proposal")
}
if field.Type != reflect.TypeOf((*ActionProposal)(nil)) {
t.Fatalf("Proposal field type = %v, want *ActionProposal", field.Type)
}
}
+52 -47
View File
@@ -3,6 +3,7 @@ package aicontracts
import (
"context"
"encoding/json"
"errors"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
@@ -19,50 +20,69 @@ import (
// OrchestratorChatService provides AI chat session management for investigations.
type OrchestratorChatService interface {
CreateSession(ctx context.Context) (*OrchestratorChatSession, error)
ExecuteStream(ctx context.Context, req OrchestratorExecuteRequest, callback OrchestratorStreamCallback) error
GetMessages(ctx context.Context, sessionID string) ([]OrchestratorMessage, error)
DeleteSession(ctx context.Context, sessionID string) error
ListAvailableTools(ctx context.Context, prompt string) []string
SetAutonomousMode(enabled bool)
// ExecuteInvestigationStream runs one Patrol investigation under the
// core-owned investigation execution profile and returns the
// structured result. There is deliberately no generic execution
// method, no tool listing outside the investigation projection, and
// no autonomy field anywhere on this interface: non-interactive
// operation grants no authority, and enterprise code can neither
// select a profile nor relax one.
ExecuteInvestigationStream(ctx context.Context, req OrchestratorInvestigationRequest, callback OrchestratorStreamCallback) (*OrchestratorInvestigationResult, error)
// ListInvestigationTools names the tools an investigation run
// offers, projected through the same profile path the run uses.
ListInvestigationTools(ctx context.Context) []string
}
// OrchestratorCommandExecutor executes commands directly (bypasses the LLM).
type OrchestratorCommandExecutor interface {
ExecuteCommand(ctx context.Context, command, targetHost string) (output string, exitCode int, err error)
// OrchestratorInvestigationRequest is one investigation run. Correlation
// identity is injected here by the orchestrator from trusted context; the
// model's proposal tool schema never carries it.
type OrchestratorInvestigationRequest struct {
SessionID string `json:"session_id,omitempty"`
Prompt string `json:"prompt"`
SystemPrompt string `json:"system_prompt,omitempty"`
MaxTurns int `json:"max_turns,omitempty"`
ExecutionID string `json:"execution_id,omitempty"`
ProposalID string `json:"proposal_id"`
FindingID string `json:"finding_id"`
InvestigationID string `json:"investigation_id"`
EvidenceIDs []string `json:"evidence_ids,omitempty"`
}
// OrchestratorInvestigationResult is the structured outcome of one
// investigation run. Proposal cardinality is first-class: consumers never
// reconstruct proposals from session messages. A non-nil Proposal is
// immutable, canonically valid, fully correlated, exposure-safe, and
// produced only by a completely successful run - ready for
// OrchestratorActionBroker.Submit.
type OrchestratorInvestigationResult struct {
Content string `json:"content"`
Proposal *ActionProposal `json:"proposal,omitempty"`
FailedProposalAttempts int `json:"failed_proposal_attempts,omitempty"`
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
// Typed investigation proposal errors surfaced across the contract
// boundary. Any of them means the run produced no actionable proposal.
var (
ErrInvestigationProposalAmbiguous = errors.New("ambiguous investigation result: multiple distinct action proposals were submitted")
ErrInvestigationProposalIntegrity = errors.New("proposal integrity violation: one tool-use id submitted conflicting payloads")
ErrInvestigationProposalAttemptsFailed = errors.New("investigation made proposal attempts but none validated")
)
// OrchestratorFindingsStore provides access to patrol findings for the orchestrator.
type OrchestratorFindingsStore interface {
Get(id string) *Finding
Update(f *Finding) bool
}
// OrchestratorApprovalStore queues fixes for human approval.
type OrchestratorApprovalStore interface {
Create(approval *OrchestratorApproval) error
}
// OrchestratorInfraContextProvider provides discovered infrastructure context.
type OrchestratorInfraContextProvider interface {
GetInfrastructureContext() string
}
// OrchestratorAutonomyProvider provides the current autonomy level.
type OrchestratorAutonomyProvider interface {
GetCurrentAutonomyLevel() string
IsFullModeUnlocked() bool
}
// OrchestratorFixVerifier verifies that a fix resolved the issue.
type OrchestratorFixVerifier interface {
VerifyFixResolved(ctx context.Context, finding *Finding) (bool, error)
}
// OrchestratorLicenseChecker provides license feature checking.
type OrchestratorLicenseChecker interface {
HasFeature(feature string) bool
}
// OrchestratorMetricsCallback receives metrics events from the orchestrator.
type OrchestratorMetricsCallback interface {
RecordInvestigationOutcome(outcome string)
@@ -78,14 +98,6 @@ type OrchestratorChatSession struct {
ID string `json:"id"`
}
// OrchestratorExecuteRequest represents a chat execution request.
type OrchestratorExecuteRequest struct {
Prompt string `json:"prompt"`
SessionID string `json:"session_id,omitempty"`
MaxTurns int `json:"max_turns,omitempty"`
AutonomousMode *bool `json:"autonomous_mode,omitempty"`
}
// OrchestratorStreamCallback is called for each streaming event.
type OrchestratorStreamCallback func(event OrchestratorStreamEvent)
@@ -240,23 +252,16 @@ type OrchestratorAIFindingsStore interface {
// OrchestratorDeps contains all dependencies for constructing an investigation orchestrator.
type OrchestratorDeps struct {
ChatService OrchestratorChatService
// CmdExecutor and ApprovalStore are the legacy command-execution side
// doors, retained only until the typed ActionBroker migration lands.
// New orchestrator code must propose through ActionBroker and never
// dispatch command text or create command-shaped approvals.
CmdExecutor OrchestratorCommandExecutor
ChatService OrchestratorChatService
Store InvestigationStore
FindingsStore OrchestratorFindingsStore
ApprovalStore OrchestratorApprovalStore // may be nil
// ActionBroker is the typed, plan-only proposal seam into the core
// action lifecycle. Tenant-bound and actor-stamped by the core
// adapter; may be nil until wiring lands.
// action lifecycle - the ONLY route from an investigation to an
// infrastructure mutation. Tenant-bound and actor-stamped by the
// core adapter. REQUIRED: a missing broker disables the
// orchestrator; there is no command-execution fallback.
ActionBroker OrchestratorActionBroker
Config InvestigationConfig
InfraContext OrchestratorInfraContextProvider // may be nil
Autonomy OrchestratorAutonomyProvider
FixVerifier OrchestratorFixVerifier
License OrchestratorLicenseChecker
Metrics OrchestratorMetricsCallback
}