Skip to content

Commit ecf9fc5

Browse files
author
bgagent
committed
fix(event-governance): close idempotency/enforcement ordering gaps from 2nd review
- policy_decision emit is now inside the release-guarded try alongside the enforce action: a retryable throttle on the audit emit (which rethrows) used to leave the idempotency marker claimed but the action un-run, so the stream retry skipped the rule entirely — dropping a cost ceiling / approval gate silently. Emit + action now share one release-on-failure path. - cancelTaskByRule swallows a benign ConditionalCheckFailedException (task raced to terminal between load and the cancel Update) as INFO instead of rethrowing, mirroring the approval path — a CCF no longer parks the record in batchItemFailures forever. - claimIdempotency rethrows retryable infra errors instead of proceeding on an unclaimed marker, which risked a double notify/escalate on the eventual retry. Nits: comment get-event-rules cache as best-effort/per-instance; note that the CLI rules-eval matcher is a dry-run copy with the parity suites as source of truth. Tests: emit-failure-release, cancel CCF swallow (no release), claim-throttle rethrow. cdk 2300 pass, cli green.
1 parent 058d9e3 commit ecf9fc5

4 files changed

Lines changed: 199 additions & 40 deletions

File tree

cdk/src/handlers/get-event-rules.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ const POLICIES_RATE_LIMIT_PER_MINUTE = Number(process.env.POLICIES_RATE_LIMIT_PE
3636

3737
const CACHE_TTL_MINUTES = 5;
3838
const CACHE_TTL_MS = CACHE_TTL_MINUTES * 60 * 1000;
39+
// Best-effort, per-Lambda-instance cache — not shared across concurrent
40+
// instances, and a blueprint edit takes up to CACHE_TTL_MINUTES to surface.
41+
// Harmless at current scale (read-only rule listing); revisit if staleness or
42+
// cross-instance consistency ever matters.
3943
const cache = new Map<string, { response: GetEventRulesResponse; expiresAt: number }>();
4044

4145
function summarizeRule(rule: EventRule): {

cdk/src/handlers/shared/event-governance-async.ts

Lines changed: 60 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,21 @@ async function cancelTaskByRule(taskId: string, rule: EventRule, reason: string)
148148
}));
149149
}
150150
} catch (err) {
151+
const name = (err as { name?: string })?.name;
152+
// Benign: the task raced to a terminal status between loadTaskForGovernance
153+
// and this Update, so the climb-only ConditionExpression rejected it. The
154+
// task is already done/cancelled — nothing to enforce. Swallow (mirrors the
155+
// approval path) rather than parking the record in batchItemFailures forever.
156+
if (name === 'ConditionalCheckFailedException') {
157+
logger.info('[event-governance] cancel_task skipped — task already terminal', {
158+
task_id: taskId,
159+
rule_id: rule.id,
160+
});
161+
return;
162+
}
151163
// cancel_task IS the enforcement action ("cancel if cost exceeds $25"). A
152164
// throttle/5xx here must retry the record, not silently let the task run on
153-
// past its ceiling. The Update's ConditionExpression makes a re-run safe.
165+
// past its ceiling.
154166
if (isRetryableInfraError(err)) throw err;
155167
logger.error('[event-governance] cancel_task failed (non-retryable) — enforcement gap', {
156168
task_id: taskId,
@@ -357,8 +369,13 @@ async function claimIdempotency(taskId: string, ruleId: string, corr: string): P
357369
return true;
358370
} catch (err) {
359371
const name = (err as { name?: string })?.name;
372+
// Marker already exists — a genuine duplicate delivery. Skip the rule.
360373
if (name === 'ConditionalCheckFailedException') return false;
361-
logger.warn('[event-governance] idempotency claim failed — proceeding', {
374+
// A throttle/5xx on the claim itself must retry the whole record rather than
375+
// proceed: proceeding on an unclaimed marker risks double-firing a notify /
376+
// escalate (which are not conditional-safe) on the eventual retry (#230).
377+
if (isRetryableInfraError(err)) throw err;
378+
logger.warn('[event-governance] idempotency claim failed (non-retryable) — proceeding', {
362379
task_id: taskId,
363380
rule_id: ruleId,
364381
error: err instanceof Error ? err.message : String(err),
@@ -426,46 +443,49 @@ export async function evaluateAsyncEventRules(
426443

427444
const enforce = rule.mode === 'enforce';
428445
const meta = buildPolicyDecisionMetadata(rule, event, enforce, corr);
429-
await emitPolicyDecision(event.task_id, { ...meta, correlation_id: corr });
430446

431-
// observe_only mode records the policy_decision above but must NOT fire the
432-
// action — that is the whole "would have fired" contract (design §8).
433-
if (!enforce) continue;
434-
435-
// An enforce action that throws a retryable infra error must retry the whole
436-
// record; release the idempotency marker first so the retry can re-claim and
437-
// re-run, rather than being suppressed as a duplicate.
447+
// The audit emit AND the enforce action share one release-guarded try: the
448+
// idempotency marker is already claimed, so if EITHER throws a retryable
449+
// error we must release the marker before the record retries — otherwise the
450+
// retry re-claims false and the rule is skipped entirely, silently dropping
451+
// the audit record (observe_only) or the enforcement action (#230).
438452
try {
439-
if (rule.action === 'notify') {
440-
notifyChannels.push(...resolveChannels(rule, ['slack']));
441-
forceFanOut = true;
442-
}
443-
444-
if (rule.action === 'escalate') {
445-
notifyChannels.push(...resolveChannels(rule, ['email', 'slack']));
446-
forceFanOut = true;
447-
}
448-
449-
if (rule.action === 'inject_nudge' && task) {
450-
await injectNudgeByRule(task, rule, event.metadata);
451-
}
452-
453-
if (rule.action === 'require_approval' && task) {
454-
await createAsyncEventApproval({
455-
task,
456-
rule,
457-
eventType: event.event_type,
458-
metadata: event.metadata,
459-
});
460-
forceFanOut = true;
461-
}
462-
463-
if (rule.action === 'cancel_task' && task && !TERMINAL_STATUSES.includes(task.status)) {
464-
await cancelTaskByRule(
465-
event.task_id,
466-
rule,
467-
rule.reason ?? `Event rule ${rule.id} triggered cancel_task`,
468-
);
453+
await emitPolicyDecision(event.task_id, { ...meta, correlation_id: corr });
454+
455+
// observe_only records the policy_decision above but must NOT fire the
456+
// action — the "would have fired" contract (design §8).
457+
if (enforce) {
458+
if (rule.action === 'notify') {
459+
notifyChannels.push(...resolveChannels(rule, ['slack']));
460+
forceFanOut = true;
461+
}
462+
463+
if (rule.action === 'escalate') {
464+
notifyChannels.push(...resolveChannels(rule, ['email', 'slack']));
465+
forceFanOut = true;
466+
}
467+
468+
if (rule.action === 'inject_nudge' && task) {
469+
await injectNudgeByRule(task, rule, event.metadata);
470+
}
471+
472+
if (rule.action === 'require_approval' && task) {
473+
await createAsyncEventApproval({
474+
task,
475+
rule,
476+
eventType: event.event_type,
477+
metadata: event.metadata,
478+
});
479+
forceFanOut = true;
480+
}
481+
482+
if (rule.action === 'cancel_task' && task && !TERMINAL_STATUSES.includes(task.status)) {
483+
await cancelTaskByRule(
484+
event.task_id,
485+
rule,
486+
rule.reason ?? `Event rule ${rule.id} triggered cancel_task`,
487+
);
488+
}
469489
}
470490
} catch (err) {
471491
await releaseIdempotency(event.task_id, rule.id, corr);

cdk/test/handlers/shared/event-governance-async-module.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -557,5 +557,133 @@ describe('event-governance-async module', () => {
557557
// The idempotency marker was released (Delete) so the retried record re-runs.
558558
expect(mockSend.mock.calls.some((c) => c[0]._type === 'Delete')).toBe(true);
559559
});
560+
561+
const isPolicyDecisionPut = (c: any) =>
562+
c[0]._type === 'Put' && c[0].input?.Item?.event_type === 'policy_decision';
563+
const isIdemClaim = (c: any) =>
564+
c[0]._type === 'Put' && c[0].input?.Item?.event_type === 'governance_idempotency';
565+
566+
test('retryable error in policy_decision emit releases idempotency and rethrows', async () => {
567+
// The audit emit runs AFTER the claim but must be release-guarded too: if it
568+
// throttles, the marker has to be released so the retry re-claims and re-runs
569+
// rather than being deduped away with the enforcement action never fired.
570+
const throttle = Object.assign(new Error('throttled'), { name: 'ThrottlingException' });
571+
mockSend.mockImplementation((cmd: any) =>
572+
isPolicyDecisionPut([cmd]) ? Promise.reject(throttle) : Promise.resolve({}),
573+
);
574+
const mod = await import('../../../src/handlers/shared/event-governance-async');
575+
mod._resetGovernanceIdempotencyCache();
576+
await expect(
577+
mod.evaluateAsyncEventRules(
578+
{
579+
task_id: 't-emit',
580+
event_id: 'e-emit',
581+
event_type: 'agent_cost_update',
582+
metadata: { cumulative_cost_usd: 30 },
583+
},
584+
{
585+
aggregateState: { cumulative_cost_usd: 30 },
586+
task: {
587+
task_id: 't-emit',
588+
status: 'RUNNING',
589+
event_rules: [
590+
{
591+
id: 'cap',
592+
on: 'agent_cost_update',
593+
when: { aggregate: { cost_usd_gte: 25 } },
594+
action: 'cancel_task',
595+
mode: 'enforce',
596+
evaluation: 'async',
597+
},
598+
],
599+
} as any,
600+
},
601+
),
602+
).rejects.toBe(throttle);
603+
expect(mockSend.mock.calls.some((c) => c[0]._type === 'Delete')).toBe(true);
604+
// The enforcement action never ran (emit failed first).
605+
expect(mockSend.mock.calls.some(isCancelUpdate)).toBe(false);
606+
});
607+
608+
test('cancel_task swallows a benign ConditionalCheckFailedException (terminal race)', async () => {
609+
// Task passed the loaded-snapshot terminal check but raced to terminal before
610+
// the Update — the climb-only condition rejects it. That must NOT park the
611+
// record in batchItemFailures; it should be swallowed and NOT released.
612+
const ccf = Object.assign(new Error('stale'), { name: 'ConditionalCheckFailedException' });
613+
mockSend.mockImplementation((cmd: any) =>
614+
isCancelUpdate([cmd]) ? Promise.reject(ccf) : Promise.resolve({}),
615+
);
616+
const mod = await import('../../../src/handlers/shared/event-governance-async');
617+
mod._resetGovernanceIdempotencyCache();
618+
// Resolves (no throw) — the record is considered handled.
619+
await expect(
620+
mod.evaluateAsyncEventRules(
621+
{
622+
task_id: 't-race',
623+
event_id: 'e-race',
624+
event_type: 'agent_cost_update',
625+
metadata: { cumulative_cost_usd: 30 },
626+
},
627+
{
628+
aggregateState: { cumulative_cost_usd: 30 },
629+
task: {
630+
task_id: 't-race',
631+
status: 'RUNNING',
632+
event_rules: [
633+
{
634+
id: 'cap',
635+
on: 'agent_cost_update',
636+
when: { aggregate: { cost_usd_gte: 25 } },
637+
action: 'cancel_task',
638+
mode: 'enforce',
639+
evaluation: 'async',
640+
},
641+
],
642+
} as any,
643+
},
644+
),
645+
).resolves.toBeDefined();
646+
// Benign CCF is not a failure — marker stays claimed, no release.
647+
expect(mockSend.mock.calls.some((c) => c[0]._type === 'Delete')).toBe(false);
648+
});
649+
650+
test('retryable error in idempotency claim rethrows instead of double-firing', async () => {
651+
// A throttle on the claim Put must retry the whole record, not proceed on an
652+
// unclaimed marker (which would risk a double notify/escalate on retry).
653+
const throttle = Object.assign(new Error('throttled'), { name: 'ProvisionedThroughputExceededException' });
654+
mockSend.mockImplementation((cmd: any) =>
655+
isIdemClaim([cmd]) ? Promise.reject(throttle) : Promise.resolve({}),
656+
);
657+
const mod = await import('../../../src/handlers/shared/event-governance-async');
658+
mod._resetGovernanceIdempotencyCache();
659+
await expect(
660+
mod.evaluateAsyncEventRules(
661+
{
662+
task_id: 't-claim',
663+
event_id: 'e-claim',
664+
event_type: 'agent_milestone',
665+
metadata: { milestone: 'pr_created' },
666+
},
667+
{
668+
task: {
669+
task_id: 't-claim',
670+
status: 'RUNNING',
671+
event_rules: [
672+
{
673+
id: 'notify',
674+
on: 'pr_created',
675+
action: 'notify',
676+
mode: 'enforce',
677+
evaluation: 'async',
678+
notify_channels: ['slack'],
679+
},
680+
],
681+
} as any,
682+
},
683+
),
684+
).rejects.toBe(throttle);
685+
// No audit emit — we bailed at the claim, before any side effect.
686+
expect(mockSend.mock.calls.some(isPolicyDecisionPut)).toBe(false);
687+
});
560688
});
561689

cli/src/commands/rules.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ function eventName(eventType: string, metadata: Record<string, unknown>): string
4646
return eventType;
4747
}
4848

49+
// Local, deliberately-minimal matcher for the `rules eval` dry-run. It does NOT
50+
// filter by `evaluation` (fixtures are single-mode) and is a third copy of the
51+
// matching logic — the authoritative implementations are
52+
// agent/src/event_governance/evaluator.py and cdk .../event-rule-evaluator.ts,
53+
// kept in lockstep by the shared-fixture parity suites. If this drifts, those
54+
// parity tests are the source of truth; keep this in sync by hand or route
55+
// through a shared package when one exists.
4956
function matchRules(fixture: FixtureFile): string[] {
5057
const name = eventName(fixture.event.event_type, fixture.event.metadata ?? {});
5158
const meta = fixture.event.metadata ?? {};

0 commit comments

Comments
 (0)