x-pack/solutions/security/plugins/discoveries/docs/action_execution_comparison.md
Branch: attack_discovery_workflows_integration
Date: 2026-03-27
Feature flag: securitySolution.attackDiscoveryWorkflowsEnabled
Attack Discovery scheduling uses a unified hybrid architecture (Option C) regardless of the feature flag state:
The feature flag controls which generation API and schedule CRUD API the UI calls, but it does not change how schedules are stored or how actions are executed. Both FF ON and FF OFF use alerting-backed schedules. Action frequency settings (onActiveAlert, onThrottleInterval, onActionGroupChange) are always enforced by the Alerting Framework's ActionScheduler.
The feature flag is not read directly on the server for schedule storage. Both the public API (elastic_assistant) and the internal API (discoveries) create alerting rules via AttackDiscoveryScheduleDataClient. The workflowConfig field on the alerting rule's params tells the executor whether to delegate generation to the workflows engine.
File: x-pack/solutions/security/plugins/discoveries/server/lib/schedules/create_schedule_data_client/index.ts
The UI reads the feature flag asynchronously and swaps which CRUD API it calls:
// use_schedule_api.ts
const enabled = await featureFlags.getBooleanValue(
'securitySolution.attackDiscoveryWorkflowsEnabled',
false
);
setIsWorkflowsEnabled(enabled);
When isWorkflowsEnabled is true, CRUD hooks target the internal discoveries API. When false, they target the public elastic_assistant API.
File: x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/schedule/logic/use_schedule_api.ts
For generation, useAttackDiscovery checks the flag and calls either callInternalGenerateApi() or callPublicGenerateApi():
File: x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/index.tsx
| Dimension | FF OFF (Legacy) | FF ON (Internal) |
|---|---|---|
| Generation API | POST /api/attack_discovery/_generate (public, elastic_assistant) | POST /internal/attack_discovery/_generate (internal, discoveries) |
| Schedule API | POST /api/attack_discovery/schedules (public) | POST /internal/attack_discovery/schedules (internal) |
| Schedule storage | Alerting Rules | Alerting Rules (same) |
| Execution engine | Alerting Framework task runner | Alerting Framework task runner (same) |
| Pipeline shape | Monolithic: retrieve + generate + validate in one executor call | Three-phase: retrieval workflow → generation workflow → validation workflow |
| Action execution | Implicit: alertsClient.report() → ActionScheduler queues actions async | Implicit: alertsClient.report() → ActionScheduler queues actions async (same) |
| Action frequency/throttling | Fully enforced | Fully enforced (same) |
| Data client | AttackDiscoveryScheduleDataClient | AttackDiscoveryScheduleDataClient (same) |
| Tag | None | attack-discovery-schedule |
| Workflow config | Not supported | workflow_config field enables delegation to workflows engine |
File: x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/register_schedule/executor.ts
The alerting rule executor:
generateAttackDiscoveries() — a monolithic graph that handles alert retrieval, LLM invocation, and validationconst { uuid: alertDocId } = alertsClient.report({
id: alertInstanceId,
actionGroup: 'default',
});
alertsClient.setAlertData({ id: alertInstanceId, payload: baseAlertDocument, context });
actionsClient.execute().After the executor completes, the Alerting Framework's ActionScheduler:
actions arrayactionsClient.bulkEnqueueExecution()Branch point (shared executor): x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/register_schedule/executor.ts
Registered workflow executor (discoveries): x-pack/solutions/security/plugins/discoveries/server/lib/schedules/workflow_executor/index.ts (workflowExecutor)
Both modes run through the same alerting rule executor (attackDiscoveryScheduleExecutor in elastic_assistant). That executor inspects params.workflowConfig: when it is present, it looks up the workflow executor factory the discoveries plugin registered during setup (getWorkflowExecutorFactory()) and delegates to it (throwing a user-facing TaskRunError if no factory is registered). The discoveries workflowExecutor then:
executeGenerationWorkflow() — invokes the three-phase workflows pipeline (alert retrieval → generation → validation)alertsClient.report({ id: alertInstanceId, actionGroup: 'default' });
alertsClient.setAlertData({ id: alertInstanceId, payload: baseAlertDocument, context });
actionsClient.execute() directly.The Alerting Framework's ActionScheduler then handles action execution identically to the legacy path — with full frequency/throttling enforcement.
Key insight: The shared executor's params.workflowConfig check is the only branch point. Actions are always handled by the framework regardless of which branch the executor takes.
File: x-pack/solutions/security/plugins/discoveries/server/lib/schedules/create_schedule_data_client/index.ts
The createScheduleDataClient() factory always returns AttackDiscoveryScheduleDataClient (alerting-backed). There is no AttackDiscoveryWorkflowScheduleDataClient — the dual-client architecture has been removed.
All CRUD routes follow a simple pattern:
const disabledResponse = await assertWorkflowsEnabled({ context, response });
if (disabledResponse) return disabledResponse; // 404 when the FF is OFF
const dataClient = await createScheduleDataClient({ ... });
await dataClient.someOperation({ id, ...params });
No branching, no fallback, no dual-client try/catch.
Every internal schedule route is gated by assertWorkflowsEnabled — when securitySolution.attackDiscoveryWorkflowsEnabled is OFF the route returns 404 Not Found (not 403). Additionally, the create and update routes call assertAlertsIndexPatternInSpace to reject a client-supplied alerts_index_pattern that targets another space or a cross-space wildcard (returning 400), so a persisted schedule is space-correct at rest.
Behavior: Works seamlessly.
Schedules are alerting rules in both modes. The two APIs apply an asymmetric tag strategy:
applyTags; filterTags: { excludeTags: ['attack-discovery-schedule'] } on read — creates untagged schedules and hides workflow-tagged schedules from its _find/by-id results.applyTags: ['attack-discovery-schedule'] on write and no filterTags on read — it is the superset view, surfacing both its own tagged rules and untagged legacy schedules.When FF is turned ON, previously untagged schedules remain visible and manageable via both APIs (the internal API sees everything). The UI automatically switches to the internal API for new schedules.
Why the asymmetry: the goal is migration continuity, not data-loss protection. The internal API deliberately surfaces legacy untagged schedules so they remain visible after the flag is turned on. The public API excludes workflow-tagged schedules so the legacy UI does not surface schedules whose workflow-only fields it cannot present. Cross-API update is safe: rulesClient.update() does a full params replacement, but the public update route now reads the existing schedule and re-attaches workflowConfig before calling updateSchedule, so editing a workflow schedule via the public API no longer silently wipes ES|QL queries or custom workflow IDs.
Behavior: Works seamlessly for execution; internally-tagged schedules are not visible via the public API.
Schedules created via the internal API are tagged with attack-discovery-schedule. When FF is OFF, the UI targets the public API. Because the public API's filterTags excludes the workflow tag on read, internally-tagged schedules do not appear in the public API's _find results. (The internal API has no filterTags, so it would still show them — but the UI does not call it while the flag is off.)
However, because all schedules are stored as alerting rules, they continue to execute normally. Re-enabling the flag restores full UI visibility via the internal API.
Because both APIs use the same underlying storage (alerting rules), toggling the feature flag never orphans schedules. There is no separate workflow-definition storage that could become inaccessible.
The Alerting Framework's ActionScheduler supports:
| Setting | Behavior |
|---|---|
onActiveAlert | Execute action every time the alert is active |
onThrottleInterval | Execute at most once per interval (e.g., "1h") |
onActionGroupChange | Execute only when alert transitions between action groups |
summary: true | Aggregate all alerts into a single action execution |
These settings are configured per-action on the alerting rule and always enforced — in both the legacy path (FF OFF) and the hybrid path (FF ON). There is no path where frequency/throttling settings are ignored.
| File | Purpose |
|---|---|
x-pack/solutions/security/plugins/discoveries/server/lib/schedules/create_schedule_data_client/index.ts | Data client factory — always returns alerting-backed client |
x-pack/solutions/security/plugins/discoveries/server/lib/schedules/workflow_executor/index.ts | Registered workflow executor (workflowExecutor) — delegates generation to workflows, reports via alertsClient |
x-pack/solutions/security/plugins/elastic_assistant/server/lib/attack_discovery/schedules/register_schedule/executor.ts | Shared rule executor + branch point — checks params.workflowConfig; runs monolithic legacy generation or delegates to the registered workflow executor |
x-pack/solutions/security/plugins/discoveries/server/routes/post/schedules/create_schedule.ts | Create route (internal API) |
x-pack/solutions/security/plugins/discoveries/server/routes/get/schedules/find_schedules.ts | Find route (internal API — unfiltered superset, no filterTags) |
x-pack/solutions/security/plugins/discoveries/server/routes/put/schedules/update_schedule.ts | Update route (internal API) |
x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/settings_flyout/schedule/logic/use_schedule_api.ts | UI hook swapper based on FF |
x-pack/solutions/security/plugins/security_solution/public/attack_discovery/pages/use_attack_discovery/index.tsx | Generation hook — FF branch for API selection |