Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
cb02bad
Updating validation for recovery delay
doakalexi Sep 2, 2026
bce43cc
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 2, 2026
a5aa772
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 3, 2026
00f662b
Addressing PR comments
doakalexi Sep 8, 2026
9b736cc
Adding UI changes
doakalexi Sep 8, 2026
06a7cd9
Merge branch 'alerting-v2/update-validation-for-recovery-delay' of gi…
doakalexi Sep 8, 2026
57e01b6
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 8, 2026
d5f8df3
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 8, 2026
1eff571
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 8, 2026
d34739c
Fixing check after merge with main
doakalexi Sep 8, 2026
a3eeb71
Merge branch 'alerting-v2/update-validation-for-recovery-delay' of gi…
doakalexi Sep 8, 2026
7a88ad9
Merge branch 'main' of github.com:elastic/kibana into alerting-v2/upd…
doakalexi Sep 8, 2026
f26d7c5
Fixing test failures
doakalexi Sep 8, 2026
350a627
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 11, 2026
e95a043
Removing comments
doakalexi Sep 11, 2026
c6ea536
Update x-pack/platform/plugins/shared/alerting_v2/server/lib/errors/e…
doakalexi Sep 11, 2026
684bc24
Update x-pack/platform/plugins/shared/alerting_v2/test/scout_alerting…
doakalexi Sep 11, 2026
11eae65
Merge branch 'main' into alerting-v2/update-validation-for-recovery-d…
doakalexi Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { RUNBOOK_ARTIFACT_TYPE, RUNBOOK_CONTENT_LIMIT } from '@kbn/alerting-v2-c
import {
createRuleDataBaseSchema,
createRuleDataSchema,
isRecoveryDelayAllowed,
updateRuleDataSchema,
IMMUTABLE_RULE_FIELDS,
getBreachEsqlQuery,
Expand Down Expand Up @@ -55,6 +56,7 @@ describe('createRuleDataSchema', () => {
metadata: { name: 'test rule', owner: 'team-a', tags: ['label-1', 'label-2'] },
time_field: 'event.created',
schedule: { every: '5m', lookback: '10m' },
recovery_strategy: 'no_breach',
grouping: { fields: ['host.name'] },
state_transition: {
pending_operator: 'AND',
Expand All @@ -72,6 +74,7 @@ describe('createRuleDataSchema', () => {
metadata: { name: 'test rule', owner: 'team-a', tags: ['label-1', 'label-2'] },
time_field: 'event.created',
schedule: { every: '5m', lookback: '10m' },
recovery_strategy: 'no_breach',
grouping: { fields: ['host.name'] },
state_transition: {
pending_operator: 'AND',
Expand Down Expand Up @@ -702,6 +705,7 @@ describe('createRuleDataSchema', () => {
it('accepts state_transition with only recovering fields', () => {
const result = createRuleDataSchema.parse({
...validCreateData,
recovery_strategy: 'no_breach',
state_transition: {
recovering_operator: 'OR',
recovering_count: 5,
Expand Down Expand Up @@ -863,6 +867,83 @@ describe('createRuleDataSchema', () => {
});
});

describe('recovery delay allowed', () => {
it('rejects a recovering_count when recovery_strategy is unset', () => {
const result = createRuleDataSchema.safeParse({
...validCreateData,
state_transition: { pending_count: 0, recovering_count: 2 },
});

expect(result.success).toBe(false);
});

it('rejects a recovering_count when recovery_strategy is "none"', () => {
const result = createRuleDataSchema.safeParse({
...validCreateData,
recovery_strategy: 'none',
state_transition: { recovering_count: 2 },
});

expect(result.success).toBe(false);
});

it('rejects a recovering_timeframe when recovery is disabled', () => {
const result = createRuleDataSchema.safeParse({
...validCreateData,
recovery_strategy: 'none',
state_transition: { recovering_timeframe: '5m' },
});

expect(result.success).toBe(false);
});

it('rejects an inert recovery delay even when no_data_strategy is "recover"', () => {
const result = createRuleDataSchema.safeParse({
...validCreateData,
recovery_strategy: 'none',
no_data_strategy: 'recover',
query: {
format: 'standalone',
breach: { query: 'FROM logs-* | LIMIT 1' },
no_data: { query: 'FROM logs-* | STATS c = COUNT(*)' },
},
state_transition: { recovering_count: 2 },
});

expect(result.success).toBe(false);
});

it('accepts recovering_count of 0 when recovery is disabled', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iiiinteresting

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can change this if needed, it is kind of weird

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to minimize the disruption from adding the new validation

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might create some drift since I plan to hide the ui field entirely, we'll see. I want to avoid over-engineering the mapper if possible

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay yeah that makes sense, I can remove it

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in this commit, 00f662b

const result = createRuleDataSchema.safeParse({
...validCreateData,
recovery_strategy: 'none',
state_transition: { pending_count: 0, recovering_count: 0 },
});

expect(result.success).toBe(true);
});

it('accepts pending-only state_transition when recovery is disabled', () => {
const result = createRuleDataSchema.safeParse({
...validCreateData,
recovery_strategy: 'none',
state_transition: { pending_count: 3 },
});

expect(result.success).toBe(true);
});

it('accepts a recovering delay when recovery_strategy is "no_breach"', () => {
const result = createRuleDataSchema.safeParse({
...validCreateData,
recovery_strategy: 'no_breach',
state_transition: { recovering_count: 2, recovering_timeframe: '5m' },
});

expect(result.success).toBe(true);
});
});

describe('artifacts envelope', () => {
const parseWithArtifact = (artifact: Record<string, unknown>) =>
createRuleDataSchema.safeParse({ ...validCreateData, artifacts: [artifact] });
Expand Down Expand Up @@ -1677,3 +1758,52 @@ describe('tagsResponseSchema', () => {
expect(() => tagsResponseSchema.parse({})).toThrow();
});
});

describe('isRecoveryDelayAllowed', () => {
it('returns true when recovery is enabled, regardless of recovering delay', () => {
expect(
isRecoveryDelayAllowed({
recovery_strategy: 'no_breach',
state_transition: { recovering_count: 3, recovering_timeframe: '5m' },
})
).toBe(true);
expect(
isRecoveryDelayAllowed({
recovery_strategy: 'query',
state_transition: { recovering_count: 3 },
})
).toBe(true);
});

it('returns true when recovery is disabled but no recovering delay is set', () => {
expect(isRecoveryDelayAllowed({ recovery_strategy: 'none' })).toBe(true);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isRecoveryDelayAllowed feels slightly misleading in this case

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in this commit, 00f662b

expect(isRecoveryDelayAllowed({ recovery_strategy: null })).toBe(true);
expect(isRecoveryDelayAllowed({})).toBe(true);
expect(isRecoveryDelayAllowed({ recovery_strategy: 'none', state_transition: {} })).toBe(true);
});

it('treats recovering_count 0 as no delay even when recovery is disabled', () => {
expect(
isRecoveryDelayAllowed({
recovery_strategy: 'none',
state_transition: { recovering_count: 0 },
})
).toBe(true);
});

it('returns false for a positive recovering delay when recovery is disabled', () => {
expect(
isRecoveryDelayAllowed({
recovery_strategy: 'none',
state_transition: { recovering_count: 1 },
})
).toBe(false);
expect(
isRecoveryDelayAllowed({
recovery_strategy: null,
state_transition: { recovering_timeframe: '5m' },
})
).toBe(false);
expect(isRecoveryDelayAllowed({ state_transition: { recovering_count: 2 } })).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,36 @@ export const isNoDataQueryProvidedForStrategy = (data: {
export const isNoDataStrategyNotEmit = (data: {
no_data_strategy?: NoDataStrategy | null;
}): boolean => data.no_data_strategy !== noDataStrategy.emit;

/**
* A recovery delay is inert when recovery is disabled (`recovery_strategy` is
* `none` or unset), so we reject it. Only a positive delay counts
* (`recovering_count > 0` or any `recovering_timeframe`); `recovering_count: 0`
* means "recover immediately" and is always allowed.
*/
export const isRecoveryDelayAllowed = (data: {
recovery_strategy?: RecoveryStrategy | null;
state_transition?: {
recovering_count?: number | null;
recovering_timeframe?: string | null;
} | null;
}): boolean => {
const recoveryEnabled =
data.recovery_strategy != null && data.recovery_strategy !== recoveryStrategy.none;
if (recoveryEnabled) {
return true;
}

const stateTransition = data.state_transition;
if (stateTransition == null) {
return true;
}

const hasRecoveryDelay =
(stateTransition.recovering_count != null && stateTransition.recovering_count > 0) ||
stateTransition.recovering_timeframe != null;
return !hasRecoveryDelay;
};
const rejectEmitNoDataStrategy = {
message: 'no_data_strategy "emit" is not currently supported.',
path: ['no_data_strategy'],
Expand Down Expand Up @@ -577,6 +607,11 @@ export const createRuleDataSchema = createRuleDataBaseSchema
path: ['query', 'no_data'],
})
.refine(isNoDataStrategyNotEmit, rejectEmitNoDataStrategy)
.refine(isRecoveryDelayAllowed, {
message:
'state_transition.recovering_count and recovering_timeframe have no effect when recovery is disabled (recovery_strategy is "none" or unset).',
path: ['state_transition', 'recovering_count'],
})
.meta({ id: 'alerting_new_rule' });

export type CreateRuleData = z.infer<typeof createRuleDataSchema>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,38 @@ describe('executeRuleOperations', () => {
);
});

it('throws when a recovering delay is set while recovery is disabled', async () => {
const ops: RuleOperation[] = [
{ operation: 'set_kind', kind: 'alert' },
{
operation: 'set_query',
query: { format: 'standalone', breach: { query: 'FROM metrics-* | STATS COUNT(*)' } },
},
{ operation: 'set_state_transition', pending_count: 0, recovering_count: 2 },
];

await expect(executeRuleOperations({}, ops)).rejects.toThrow(
'state_transition.recovering_count and recovering_timeframe have no effect when recovery is disabled'
);
await expect(executeRuleOperations({}, ops)).rejects.toBeInstanceOf(
RuleOperationValidationError
);
});

it('allows recovering_count 0 (immediate) while recovery is disabled', async () => {
const ops: RuleOperation[] = [
{ operation: 'set_kind', kind: 'alert' },
{
operation: 'set_query',
query: { format: 'standalone', breach: { query: 'FROM metrics-* | STATS COUNT(*)' } },
},
{ operation: 'set_state_transition', pending_count: 0, recovering_count: 0 },
];

const result = await executeRuleOperations({}, ops);
expect(result.data.state_transition).toEqual({ pending_count: 0, recovering_count: 0 });
});

it('throws when signal rule uses composed query format', async () => {
const ops: RuleOperation[] = [
{ operation: 'set_kind', kind: 'signal' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
isStateTransitionAllowed,
isSignalUsingStandaloneFormat,
isSignalQueryBreachOnly,
isRecoveryDelayAllowed,
isRecoveryQueryConsistentWithStrategy,
isRecoveryQueryProvidedForStrategy,
isNoDataQueryConsistentWithStrategy,
Expand Down Expand Up @@ -570,6 +571,12 @@ export const executeRuleOperations = async (
);
}

if (!isRecoveryDelayAllowed(next)) {
throw new RuleOperationValidationError(
'state_transition.recovering_count and recovering_timeframe have no effect when recovery is disabled (recovery_strategy is "none" or unset).'
);
}

return {
data: next,
...(lastQueryColumns ? { queryColumns: lastQueryColumns } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ backwards compatible. Renaming or removing a code is a breaking change.
| `INVALID_STATE_TRANSITION` | 400 | `state_transition` is incompatible with the rule's `kind` | `{ rule_id, kind, transition }` |
| `INVALID_SIGNAL_RULE` | 400 | An update would leave a signal rule in an invalid shape | `{ rule_id, rule_kind }` |
| `INVALID_RULE_QUERY_CONFIG` | 400 | An update would desynchronize a `recovery_strategy`/`no_data_strategy` from its `query.recovery`/`query.no_data` block | `{ rule_id }` |
| `INVALID_STATE_TRANSITION_CONFIG` | 400 | A recovery delay (`state_transition.recovering_count`/`recovering_timeframe`) is set while recovery is disabled, where it is inert | `{ rule_id }` |
| `BULK_QUERY_MATCH_LIMIT_EXCEEDED` | 400 | By-query bulk operation with `force: true` matched more resources than the per-request cap; rejected before any resource is mutated | `{ match_count, limit }` |
| `IMMUTABLE_FIELDS_CHANGED` | 400 | PUT (upsert) request changes a field flagged as immutable | `{ fields }` |
| `INVALID_FILTER_FIELD` | 400 | The `filter` references a field that is not in the allow-list | `{ field, allowed_fields }` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export const ALERTING_ERROR_CODES = {
INVALID_ARTIFACT_DATA: 'INVALID_ARTIFACT_DATA',
/** `state_transition` cannot be applied to the rule's `kind`. */
INVALID_STATE_TRANSITION: 'INVALID_STATE_TRANSITION',
/**
* A rule's merged shape carries a recovery delay
* (`state_transition.recovering_count` / `recovering_timeframe`) while
* recovery is disabled (`recovery_strategy` is `none` or unset), where it
* would never take effect.
*/
Comment thread
doakalexi marked this conversation as resolved.
Outdated
INVALID_STATE_TRANSITION_CONFIG: 'INVALID_STATE_TRANSITION_CONFIG',
/** A signal rule's merged shape violates signal constraints. */
INVALID_SIGNAL_RULE: 'INVALID_SIGNAL_RULE',
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,36 @@ describe('RulesClient', () => {
).resolves.not.toThrow();
});

it('throws 400 when disabling recovery leaves a stored recovering delay inert', async () => {
const client = createClient();

const existingAttributes: RuleSavedObjectAttributes = {
...baseSoAttrs,
kind: 'alert',
recovery_strategy: 'no_breach',
state_transition: { recovering_count: 3 },
};

rulesSavedObjectService.get.mockResolvedValueOnce({
id: 'rule-id-inert-recovery-delay',
attributes: existingAttributes,
version: 'WzEsMV0=',
});

await expect(
client.updateRule({
id: 'rule-id-inert-recovery-delay',
data: { recovery_strategy: 'none' },
})
).rejects.toMatchObject({
output: { statusCode: 400 },
message:
'state_transition.recovering_count and recovering_timeframe have no effect when recovery is disabled (recovery_strategy is "none" or unset).',
});

expect(rulesSavedObjectService.update).not.toHaveBeenCalled();
});

it('throws 400 when updating a signal rule query to composed format', async () => {
const client = createClient();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,58 @@ describe('utils', () => {

expect(() => validateMergedRuleAttributes('rule-1', attrs)).not.toThrow();
});

it('throws INVALID_STATE_TRANSITION_CONFIG (400) when a recovering delay is set while recovery is disabled', () => {
const attrs = createRuleSoAttributes({
kind: 'alert',
recovery_strategy: 'none',
state_transition: { recovering_count: 3 },
});

expect(() => validateMergedRuleAttributes('rule-1', attrs)).toThrow(
expect.objectContaining({
isBoom: true,
output: expect.objectContaining({ statusCode: 400 }),
message:
'state_transition.recovering_count and recovering_timeframe have no effect when recovery is disabled (recovery_strategy is "none" or unset).',
data: { code: 'INVALID_STATE_TRANSITION_CONFIG', details: { rule_id: 'rule-1' } },
})
);
});

it('throws INVALID_STATE_TRANSITION_CONFIG when a recovering_timeframe is set while recovery is unset', () => {
const attrs = createRuleSoAttributes({
kind: 'alert',
recovery_strategy: undefined,
state_transition: { recovering_timeframe: '5m' },
});

expect(() => validateMergedRuleAttributes('rule-1', attrs)).toThrow(
expect.objectContaining({
data: { code: 'INVALID_STATE_TRANSITION_CONFIG', details: { rule_id: 'rule-1' } },
})
);
});

it('does not throw for a recovering delay when recovery is enabled', () => {
const attrs = createRuleSoAttributes({
kind: 'alert',
recovery_strategy: 'no_breach',
state_transition: { recovering_count: 3, recovering_timeframe: '5m' },
});

expect(() => validateMergedRuleAttributes('rule-1', attrs)).not.toThrow();
});

it('does not throw for recovering_count 0 (immediate) when recovery is disabled', () => {
const attrs = createRuleSoAttributes({
kind: 'alert',
recovery_strategy: 'none',
state_transition: { pending_count: 0, recovering_count: 0 },
});

expect(() => validateMergedRuleAttributes('rule-1', attrs)).not.toThrow();
});
});

describe('pickImmutable', () => {
Expand Down
Loading
Loading