diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 55e15b147..073b25988 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -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" + ] } ] } diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 6b95968db..629d1a53c 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 5c1207f9c..95ae58b91 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4fe3304bb..b7571b8da 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 806628826..866cd9096 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index bf36dd358..1014ab686 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index a4c1747e9..d0b86e7ac 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/internal/ai/patrol_findings.go b/internal/ai/patrol_findings.go index 160d05d80..270f24b23 100644 --- a/internal/ai/patrol_findings.go +++ b/internal/ai/patrol_findings.go @@ -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(). diff --git a/internal/ai/patrol_findings_additional_test.go b/internal/ai/patrol_findings_additional_test.go index abb01b486..04ec984fa 100644 --- a/internal/ai/patrol_findings_additional_test.go +++ b/internal/ai/patrol_findings_additional_test.go @@ -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) + } + } +} diff --git a/internal/api/ai_handlers.go b/internal/api/ai_handlers.go index e23b5df31..aba09113e 100644 --- a/internal/api/ai_handlers.go +++ b/internal/api/ai_handlers.go @@ -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 { diff --git a/internal/api/ai_handlers_more_test.go b/internal/api/ai_handlers_more_test.go index 0c58122a6..f916ac847 100644 --- a/internal/api/ai_handlers_more_test.go +++ b/internal/api/ai_handlers_more_test.go @@ -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") - } -} diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 50bff86df..c2c2b8c5e 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -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} diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index e8577c548..0db3a69b0 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -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) } } diff --git a/internal/api/patrol_action_broker.go b/internal/api/patrol_action_broker.go index e61f00927..6cd9884c0 100644 --- a/internal/api/patrol_action_broker.go +++ b/internal/api/patrol_action_broker.go @@ -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, }) diff --git a/internal/api/router.go b/internal/api/router.go index 4e4bf5660..4984caaa4 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) diff --git a/pkg/aicontracts/action_broker.go b/pkg/aicontracts/action_broker.go index 85a608985..8cd5eb54a 100644 --- a/pkg/aicontracts/action_broker.go +++ b/pkg/aicontracts/action_broker.go @@ -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"` } diff --git a/pkg/aicontracts/contracts_test.go b/pkg/aicontracts/contracts_test.go index 57be750c3..1d6c6e685 100644 --- a/pkg/aicontracts/contracts_test.go +++ b/pkg/aicontracts/contracts_test.go @@ -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) + } +} diff --git a/pkg/aicontracts/orchestrator_deps.go b/pkg/aicontracts/orchestrator_deps.go index 32eb3bee6..824465a34 100644 --- a/pkg/aicontracts/orchestrator_deps.go +++ b/pkg/aicontracts/orchestrator_deps.go @@ -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 }