Skip to content

Commit 058d9e3

Browse files
author
bgagent
committed
fix(event-governance): close enforcement silent-failure gaps from review
Multi-agent review surfaced that the branch hardened three DB read/persist paths against throttles but left the actual enforcement writes swallowing them — defeating the ceiling/approval guarantees the branch existed to fix. - cancel_task and async require_approval now rethrow retryable infra errors (throttle/5xx) so the FanOut record retries instead of silently letting a task run past its cost ceiling / skip its approval gate; benign conditional failures on the approval path are classified and swallowed as INFO - lift isRetryableInfraError into shared retryable-error.ts (approval module consumes it without a circular import) - release the idempotency marker when an enforce action rethrows, so the retried record can re-claim and re-run rather than being deduped away - observe_only mode no longer fires notify/escalate side effects — gate the whole action block on enforce (was only guarding nudge/approval/cancel) - inject_nudge rethrows retryable errors too - parseEventRules (TS + Python) logs dropped malformed rules instead of silently vanishing a ceiling rule; Python now requires id AND on (no more KeyError on missing on) - add turn_count_gte to the inline BlueprintEventRule.when.aggregate type - drop severity 'critical' from schema + both type mirrors (the agent PolicyEngine only knows low/medium/high) - remove dead evaluation-filter branch in bgagent rules eval Tests: async require_approval (enforce + observe), inject_nudge truncation, idempotency dedup skip, cancel terminal-status guard, retryable-release-and- rethrow, unknown-pack 422, and a new event-rule-pack-resolver suite covering inline/pack merge + override. cdk 2297 pass, agent + cli green.
1 parent 7eaf8d7 commit 058d9e3

13 files changed

Lines changed: 543 additions & 58 deletions

File tree

agent/event-rules/schema.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"reason": { "type": "string" },
6060
"severity": {
6161
"type": "string",
62-
"enum": ["low", "medium", "high", "critical"]
62+
"enum": ["low", "medium", "high"]
6363
},
6464
"timeout_s": { "type": "integer", "minimum": 30, "maximum": 3600 },
6565
"notify_channels": {

agent/src/event_governance/evaluator.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22

33
from __future__ import annotations
44

5+
import logging
56
from dataclasses import dataclass, field
67
from typing import Any
78

9+
logger = logging.getLogger(__name__)
10+
811

912
@dataclass(frozen=True)
1013
class EventRule:
@@ -144,8 +147,15 @@ def parse_rules(
144147
if not raw:
145148
return []
146149
out: list[EventRule] = []
147-
for item in raw:
148-
if not isinstance(item, dict) or not item.get("id"):
150+
for index, item in enumerate(raw):
151+
# Require both id and on, matching the CDK parser — a rule missing either
152+
# is dropped with a warning rather than silently vanishing or raising a
153+
# KeyError mid-loop (a dropped ceiling/approval rule = zero enforcement).
154+
if not isinstance(item, dict) or not item.get("id") or not item.get("on"):
155+
logger.warning(
156+
"[event-governance] dropped malformed rule — missing id/on (index=%s)",
157+
index,
158+
)
149159
continue
150160
out.append(
151161
EventRule(

cdk/src/constructs/blueprint.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export interface BlueprintEventRule {
5151
readonly on: string;
5252
readonly when?: {
5353
readonly fields?: Readonly<Record<string, unknown>>;
54-
readonly aggregate?: { readonly cost_usd_gte?: number };
54+
readonly aggregate?: { readonly cost_usd_gte?: number; readonly turn_count_gte?: number };
5555
};
5656
readonly action: string;
5757
readonly mode: string;

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
import { ulid } from 'ulid';
3131
import type { EventRule } from './event-governance-types';
3232
import { logger } from './logger';
33+
import { isRetryableInfraError } from './retryable-error';
3334
import type { TaskRecord } from './types';
3435
import { TaskStatus } from '../../constructs/task-status';
3536

@@ -131,12 +132,29 @@ export async function createAsyncEventApproval(options: {
131132
}
132133
return requestId;
133134
} catch (err) {
134-
logger.warn('[event-governance] async require_approval skipped', {
135+
const name = (err as { name?: string })?.name;
136+
// Benign: the task already left RUNNING (TransactWrite condition), or this
137+
// request_id already exists (a settled duplicate). The gate did not need to
138+
// fire again — swallow without a retry.
139+
if (name === 'ConditionalCheckFailedException' || name === 'TransactionCanceledException') {
140+
logger.info('[event-governance] async require_approval already settled', {
141+
task_id: options.task.task_id,
142+
rule_id: options.rule.id,
143+
status: options.task.status,
144+
});
145+
return undefined;
146+
}
147+
// A throttle/5xx must retry the record — otherwise the approval row is never
148+
// written, the task never transitions to AWAITING_APPROVAL, and the gate is
149+
// silently skipped while the caller reports it fired (#230).
150+
if (isRetryableInfraError(err)) throw err;
151+
logger.error('[event-governance] async require_approval failed (non-retryable) — enforcement gap', {
135152
task_id: options.task.task_id,
136153
rule_id: options.rule.id,
137154
status: options.task.status,
155+
error_id: 'EVENT_GOV_APPROVAL_FAILED',
138156
error: err instanceof Error ? err.message : String(err),
139157
});
140-
return undefined;
158+
throw err;
141159
}
142160
}

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

Lines changed: 72 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
*/
2323

2424
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
25-
import { DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
25+
import { DeleteCommand, DynamoDBDocumentClient, GetCommand, PutCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb';
2626
import { ulid } from 'ulid';
2727
import { createAsyncEventApproval } from './event-governance-approval';
2828
import {
@@ -34,6 +34,7 @@ import {
3434
} from './event-rule-evaluator';
3535
import { logger } from './logger';
3636
import { coerceNumericOrNull } from './numeric';
37+
import { isRetryableInfraError } from './retryable-error';
3738
import { NUDGE_MAX_MESSAGE_LENGTH, type EventRule, type TaskRecord } from './types';
3839
import { computeTtlEpoch } from './validation';
3940
import { TaskStatus, TERMINAL_STATUSES } from '../../constructs/task-status';
@@ -67,21 +68,10 @@ export interface AsyncGovernanceResult {
6768
readonly forceFanOut: boolean;
6869
}
6970

70-
/** Names of AWS SDK errors that a stream-record retry can plausibly clear
71-
* (throttles, transient 5xx). Distinguishes "retry the record" from a
72-
* poison-pill that would stall the shard forever. */
73-
const RETRYABLE_AWS_ERROR = /Throttling|ProvisionedThroughputExceeded|RequestLimitExceeded|ServiceUnavailable|InternalServerError|InternalFailure|TransactionInProgress|5\d\d/i;
74-
75-
export function isRetryableInfraError(err: unknown): boolean {
76-
if (err && typeof err === 'object') {
77-
const e = err as { name?: string; $retryable?: unknown; $metadata?: { httpStatusCode?: number } };
78-
if (e.$retryable) return true;
79-
const status = e.$metadata?.httpStatusCode;
80-
if (typeof status === 'number' && status >= 500) return true;
81-
if (e.name && RETRYABLE_AWS_ERROR.test(e.name)) return true;
82-
}
83-
return false;
84-
}
71+
// Re-exported so existing importers/tests keep resolving it from this module;
72+
// the canonical definition lives in ./retryable-error so the approval path can
73+
// consume it without a circular import back into this module.
74+
export { isRetryableInfraError };
8575

8676
export async function loadTaskForGovernance(taskId: string): Promise<TaskRecord | undefined> {
8777
if (!TASK_TABLE) return undefined;
@@ -158,11 +148,17 @@ async function cancelTaskByRule(taskId: string, rule: EventRule, reason: string)
158148
}));
159149
}
160150
} catch (err) {
161-
logger.warn('[event-governance] cancel_task skipped', {
151+
// cancel_task IS the enforcement action ("cancel if cost exceeds $25"). A
152+
// 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.
154+
if (isRetryableInfraError(err)) throw err;
155+
logger.error('[event-governance] cancel_task failed (non-retryable) — enforcement gap', {
162156
task_id: taskId,
163157
rule_id: rule.id,
158+
error_id: 'EVENT_GOV_CANCEL_FAILED',
164159
error: err instanceof Error ? err.message : String(err),
165160
});
161+
throw err;
166162
}
167163
}
168164

@@ -195,7 +191,12 @@ async function injectNudgeByRule(
195191
ConditionExpression: 'attribute_not_exists(nudge_id)',
196192
}));
197193
} catch (err) {
198-
logger.warn('[event-governance] inject_nudge skipped', {
194+
// The nudge row is keyed by a fresh ULID, so the attribute_not_exists guard
195+
// only ever fires on a genuine ULID collision (never) — any CCF here is
196+
// effectively an infra fault. Rethrow retryable errors so steering isn't
197+
// silently dropped on a transient blip.
198+
if (isRetryableInfraError(err)) throw err;
199+
logger.warn('[event-governance] inject_nudge skipped (non-retryable)', {
199200
task_id: task.task_id,
200201
rule_id: rule.id,
201202
error: err instanceof Error ? err.message : String(err),
@@ -366,6 +367,27 @@ async function claimIdempotency(taskId: string, ruleId: string, corr: string): P
366367
}
367368
}
368369

370+
/** Delete the idempotency marker so a rethrown (retried) record can re-claim
371+
* and re-run the enforcement action. Best-effort: if this delete is itself
372+
* throttled the marker survives and the retry is a no-op (the action is lost),
373+
* but that is strictly better than double-firing, and the enforce actions
374+
* (cancel/approval) are themselves conditional so re-running is safe. */
375+
async function releaseIdempotency(taskId: string, ruleId: string, corr: string): Promise<void> {
376+
if (!EVENTS_TABLE) return;
377+
try {
378+
await ddb.send(new DeleteCommand({
379+
TableName: EVENTS_TABLE,
380+
Key: { task_id: taskId, event_id: idempotencyEventId(taskId, ruleId, corr) },
381+
}));
382+
} catch (err) {
383+
logger.warn('[event-governance] idempotency release failed', {
384+
task_id: taskId,
385+
rule_id: ruleId,
386+
error: err instanceof Error ? err.message : String(err),
387+
});
388+
}
389+
}
390+
369391
/** Channels for a notify/escalate rule; falls back to a default so a rule with
370392
* no ``notify_channels`` still delivers rather than silently doing nothing. */
371393
function resolveChannels(rule: EventRule, fallback: string[]): string[] {
@@ -406,42 +428,48 @@ export async function evaluateAsyncEventRules(
406428
const meta = buildPolicyDecisionMetadata(rule, event, enforce, corr);
407429
await emitPolicyDecision(event.task_id, { ...meta, correlation_id: corr });
408430

409-
if (rule.action === 'observe_only' || (rule.action === 'require_approval' && !enforce)) {
410-
continue;
411-
}
412-
413-
if (rule.action === 'notify') {
414-
notifyChannels.push(...resolveChannels(rule, ['slack']));
415-
forceFanOut = true;
416-
}
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.
438+
try {
439+
if (rule.action === 'notify') {
440+
notifyChannels.push(...resolveChannels(rule, ['slack']));
441+
forceFanOut = true;
442+
}
417443

418-
if (rule.action === 'escalate') {
419-
notifyChannels.push(...resolveChannels(rule, ['email', 'slack']));
420-
forceFanOut = true;
421-
}
444+
if (rule.action === 'escalate') {
445+
notifyChannels.push(...resolveChannels(rule, ['email', 'slack']));
446+
forceFanOut = true;
447+
}
422448

423-
if (rule.action === 'inject_nudge' && enforce && task) {
424-
await injectNudgeByRule(task, rule, event.metadata);
425-
}
449+
if (rule.action === 'inject_nudge' && task) {
450+
await injectNudgeByRule(task, rule, event.metadata);
451+
}
426452

427-
if (rule.action === 'require_approval' && enforce && task) {
428-
await createAsyncEventApproval({
429-
task,
430-
rule,
431-
eventType: event.event_type,
432-
metadata: event.metadata,
433-
});
434-
forceFanOut = true;
435-
}
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+
}
436462

437-
if (rule.action === 'cancel_task' && enforce && task) {
438-
if (!TERMINAL_STATUSES.includes(task.status)) {
463+
if (rule.action === 'cancel_task' && task && !TERMINAL_STATUSES.includes(task.status)) {
439464
await cancelTaskByRule(
440465
event.task_id,
441466
rule,
442467
rule.reason ?? `Event rule ${rule.id} triggered cancel_task`,
443468
);
444469
}
470+
} catch (err) {
471+
await releaseIdempotency(event.task_id, rule.id, corr);
472+
throw err;
445473
}
446474
}
447475

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export interface EventRule {
5151
readonly mode: EventRuleMode;
5252
readonly evaluation: EventRuleEvaluation;
5353
readonly reason?: string;
54-
readonly severity?: 'low' | 'medium' | 'high' | 'critical';
54+
readonly severity?: 'low' | 'medium' | 'high';
5555
readonly timeout_s?: number;
5656
readonly notify_channels?: readonly NotificationChannelWithWebhook[];
5757
readonly rule_pack_id?: string;

cdk/src/handlers/shared/event-rule-evaluator.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,21 @@ export function buildPolicyDecisionMetadata(
154154
export function parseEventRules(raw: unknown): EventRule[] {
155155
if (!Array.isArray(raw)) return [];
156156
const out: EventRule[] = [];
157-
for (const item of raw) {
158-
if (!item || typeof item !== 'object') continue;
157+
for (const [index, item] of raw.entries()) {
158+
if (!item || typeof item !== 'object') {
159+
logger.warn('[event-governance] dropped malformed rule — not an object', { index });
160+
continue;
161+
}
159162
const r = item as Record<string, unknown>;
160-
if (typeof r.id !== 'string' || typeof r.on !== 'string') continue;
163+
if (typeof r.id !== 'string' || typeof r.on !== 'string') {
164+
// Fail loud: a dropped ceiling/approval rule means zero enforcement with no
165+
// other signal. Mirror the fail-loud stance the pack resolver takes (#230).
166+
logger.warn('[event-governance] dropped malformed rule — missing id/on', {
167+
index,
168+
id: typeof r.id === 'string' ? r.id : undefined,
169+
});
170+
continue;
171+
}
161172
out.push({
162173
id: r.id,
163174
on: r.on,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* MIT No Attribution
3+
*
4+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy of
7+
* the Software without restriction, including without limitation the rights to
8+
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9+
* the Software, and to permit persons to whom the Software is furnished to do so.
10+
*
11+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
12+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
13+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
14+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
15+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
16+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
17+
* SOFTWARE.
18+
*/
19+
20+
/** Names of AWS SDK errors that a stream-record retry can plausibly clear
21+
* (throttles, transient 5xx). Distinguishes "retry the record" from a
22+
* poison-pill that would stall the shard forever. */
23+
const RETRYABLE_AWS_ERROR = /Throttling|ProvisionedThroughputExceeded|RequestLimitExceeded|ServiceUnavailable|InternalServerError|InternalFailure|TransactionInProgress|5\d\d/i;
24+
25+
/**
26+
* True when an error is a transient infra fault (throttle / 5xx) that a stream
27+
* retry can clear — as opposed to a benign conditional failure or a
28+
* deterministic client error that would just fail again. Governance
29+
* enforcement paths rethrow these so the FanOut record enters
30+
* ``batchItemFailures`` instead of silently skipping a cancel/approval (#230).
31+
*/
32+
export function isRetryableInfraError(err: unknown): boolean {
33+
if (err && typeof err === 'object') {
34+
const e = err as { name?: string; $retryable?: unknown; $metadata?: { httpStatusCode?: number } };
35+
if (e.$retryable) return true;
36+
const status = e.$metadata?.httpStatusCode;
37+
if (typeof status === 'number' && status >= 500) return true;
38+
if (e.name && RETRYABLE_AWS_ERROR.test(e.name)) return true;
39+
}
40+
return false;
41+
}

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

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,4 +143,22 @@ describe('get-event-rules', () => {
143143
const ruleIds = body.data.rules.map((r: { rule_id: string }) => r.rule_id);
144144
expect(ruleIds).toContain('observe-repo-setup');
145145
});
146+
147+
test('422 VALIDATION_ERROR when repo pins an unknown event-rule-pack', async () => {
148+
mockSend.mockResolvedValue({});
149+
mockLoadRepoConfig.mockResolvedValue({
150+
repo: 'owner/repo',
151+
status: 'active',
152+
onboarded_at: '',
153+
updated_at: '',
154+
event_rule_pack: { id: 'does-not-exist', version: '9.9.9' },
155+
});
156+
157+
const res = await handler(makeEvent());
158+
// Fail loud rather than silently applying zero governance rules (#230).
159+
expect(res.statusCode).toBe(422);
160+
const body = JSON.parse(res.body);
161+
expect(body.error.code).toBe('VALIDATION_ERROR');
162+
expect(body.error.message).toContain('does-not-exist@9.9.9');
163+
});
146164
});

0 commit comments

Comments
 (0)