Skip to content

Commit 607fb61

Browse files
author
bgagent
committed
fix(event-governance): durable aggregate high-water marks; resolve rule-language question (#230)
Aggregate ceiling rules were unreliable: - turn_count_gte rules never fired — the agent emitted `turn` but every evaluator reads `turn_count` (NaN → no match). Agent now also emits the cumulative aliases `turn_count` / `cumulative_cost_usd` that the rules read. - cost/turn aggregates were reconstructed from the current event only, so the per-session SDK reset (PR-fix retries, container restart) undercounted them. evaluateAsyncEventRules now persists a monotonic high-water mark on the TaskRecord (climb-only conditional UpdateItem) and evaluates against it, so ceilings survive restarts. Fanout's ad-hoc reconstruction is removed. Resolves the §10 rule-language open question: declarative field matchers are permanent; Cedar-on-events is rejected (Cedar is authorization, has no aggregation, and would add a third Cedar runtime for no gain). ponytail: high-water is max(session totals), not a cross-session SUM — upgrade path noted on the TaskRecord fields if PR-fix retries must accrue.
1 parent f2b7476 commit 607fb61

7 files changed

Lines changed: 197 additions & 20 deletions

File tree

agent/src/progress_writer.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,10 @@ def write_agent_turn(
612612
"agent_turn",
613613
{
614614
"turn": turn,
615+
# Cumulative alias read by event-governance aggregate rules
616+
# (turn_count_gte). ``turn`` is the SDK's running turn index, so
617+
# it is already the per-session cumulative count. See #230.
618+
"turn_count": turn,
615619
"model": model,
616620
"thinking_preview": self._preview(thinking),
617621
"text_preview": self._preview(text),
@@ -686,9 +690,14 @@ def write_agent_cost_update(
686690
"agent_cost_update",
687691
{
688692
"cost_usd": cost_usd,
693+
# Cumulative alias read by event-governance aggregate rules
694+
# (cost_usd_gte). SDK ``total_cost_usd`` is the running
695+
# session total, so it is already cumulative. See #230.
696+
"cumulative_cost_usd": cost_usd,
689697
"input_tokens": input_tokens,
690698
"output_tokens": output_tokens,
691699
"turn": turn,
700+
"turn_count": turn,
692701
},
693702
)
694703

cdk/src/handlers/fanout-task-events.ts

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,24 +1271,12 @@ export const handler = async (
12711271
try {
12721272
if (EVENT_GOVERNANCE_ENABLED) {
12731273
const taskForGov = await loadTaskForGovernance(ev.task_id);
1274-
const rawCost = ev.metadata?.cumulative_cost_usd ?? ev.metadata?.cost_usd;
1275-
const parsedCost = typeof rawCost === 'number' ? rawCost : Number(rawCost);
1276-
const rawTurns = ev.metadata?.turn_count;
1277-
const parsedTurns = typeof rawTurns === 'number' ? rawTurns : Number(rawTurns);
1278-
const aggregateState = ev.event_type === 'agent_cost_update'
1279-
|| ev.event_type === 'agent_turn'
1280-
? {
1281-
...(ev.event_type === 'agent_cost_update' && {
1282-
cumulative_cost_usd: Number.isFinite(parsedCost) ? parsedCost : taskForGov?.cost_usd,
1283-
}),
1284-
...(ev.event_type === 'agent_turn' && Number.isFinite(parsedTurns) && {
1285-
turn_count: parsedTurns,
1286-
}),
1287-
}
1288-
: undefined;
1274+
// Aggregate high-water tracking is owned by evaluateAsyncEventRules,
1275+
// which persists and resolves the durable marks (#230); the handler
1276+
// just forwards the event and task record.
12891277
const govResult = await evaluateAsyncEventRules(
12901278
{ ...ev, metadata: ev.metadata ?? {} },
1291-
{ task: taskForGov, aggregateState },
1279+
{ task: taskForGov },
12921280
);
12931281
governanceForcedChannels = govResult.notifyChannels.filter(
12941282
(ch): ch is NotificationChannel => ch in DISPATCHERS,

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

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
buildPolicyDecisionMetadata,
3030
matchEventRules,
3131
parseEventRules,
32+
type AggregateState,
3233
type EvaluableEvent,
3334
} from './event-rule-evaluator';
3435
import { logger } from './logger';
@@ -186,6 +187,88 @@ function correlationId(event: EvaluableEvent & { event_id?: string }, rule: Even
186187
return `${base}:${rule.id}`;
187188
}
188189

190+
/** Extract the incoming cumulative cost/turn from a cost/turn event's metadata. */
191+
function incomingAggregate(event: EvaluableEvent): { cost?: number; turns?: number } {
192+
const meta = event.metadata;
193+
const out: { cost?: number; turns?: number } = {};
194+
if (event.event_type === 'agent_cost_update') {
195+
const raw = meta.cumulative_cost_usd ?? meta.cost_usd;
196+
const cost = typeof raw === 'number' ? raw : Number(raw);
197+
if (Number.isFinite(cost)) out.cost = cost;
198+
}
199+
if (event.event_type === 'agent_turn' || event.event_type === 'agent_cost_update') {
200+
const raw = meta.turn_count ?? meta.turn;
201+
const turns = typeof raw === 'number' ? raw : Number(raw);
202+
if (Number.isFinite(turns)) out.turns = turns;
203+
}
204+
return out;
205+
}
206+
207+
/**
208+
* Bump the durable high-water marks on the TaskRecord and return the effective
209+
* aggregate state for rule evaluation (#230). Uses ``if_not_exists`` + a max
210+
* ConditionExpression so the value only ever climbs — a stream retry or an
211+
* out-of-order event can't lower it, and it survives the per-session SDK reset
212+
* across container restarts / PR-fix retries. On any DDB error we fall back to
213+
* the just-observed value so evaluation still proceeds (fail-open to the live
214+
* reading, never below it).
215+
*/
216+
async function persistAndResolveAggregate(
217+
taskId: string,
218+
event: EvaluableEvent,
219+
task: TaskRecord | undefined,
220+
): Promise<AggregateState | undefined> {
221+
const incoming = incomingAggregate(event);
222+
if (incoming.cost === undefined && incoming.turns === undefined) {
223+
// Not a cost/turn event — use whatever is already persisted.
224+
if (!task) return undefined;
225+
return { cumulative_cost_usd: task.gov_cumulative_cost_usd, turn_count: task.gov_cumulative_turn_count };
226+
}
227+
228+
const priorCost = task?.gov_cumulative_cost_usd ?? 0;
229+
const priorTurns = task?.gov_cumulative_turn_count ?? 0;
230+
const resolved: AggregateState = {
231+
cumulative_cost_usd: incoming.cost !== undefined ? Math.max(priorCost, incoming.cost) : task?.gov_cumulative_cost_usd,
232+
turn_count: incoming.turns !== undefined ? Math.max(priorTurns, incoming.turns) : task?.gov_cumulative_turn_count,
233+
};
234+
235+
if (!TASK_TABLE) return resolved;
236+
237+
const sets: string[] = [];
238+
const conds: string[] = [];
239+
const values: Record<string, unknown> = {};
240+
if (incoming.cost !== undefined) {
241+
sets.push('gov_cumulative_cost_usd = :c');
242+
conds.push('(attribute_not_exists(gov_cumulative_cost_usd) OR gov_cumulative_cost_usd < :c)');
243+
values[':c'] = incoming.cost;
244+
}
245+
if (incoming.turns !== undefined) {
246+
sets.push('gov_cumulative_turn_count = :t');
247+
conds.push('(attribute_not_exists(gov_cumulative_turn_count) OR gov_cumulative_turn_count < :t)');
248+
values[':t'] = incoming.turns;
249+
}
250+
try {
251+
await ddb.send(new UpdateCommand({
252+
TableName: TASK_TABLE,
253+
Key: { task_id: taskId },
254+
UpdateExpression: `SET ${sets.join(', ')}`,
255+
// Only write when at least one mark climbs; a no-op (retry with equal or
256+
// lower value) fails the condition and is silently skipped.
257+
ConditionExpression: `attribute_exists(task_id) AND (${conds.join(' OR ')})`,
258+
ExpressionAttributeValues: values,
259+
}));
260+
} catch (err) {
261+
const name = (err as { name?: string })?.name;
262+
if (name !== 'ConditionalCheckFailedException') {
263+
logger.warn('[event-governance] aggregate high-water update failed', {
264+
task_id: taskId,
265+
error: err instanceof Error ? err.message : String(err),
266+
});
267+
}
268+
}
269+
return resolved;
270+
}
271+
189272
function idempotencyEventId(taskId: string, ruleId: string, corr: string): string {
190273
return `gov-idem#${taskId}#${ruleId}#${corr}`;
191274
}
@@ -237,9 +320,15 @@ export async function evaluateAsyncEventRules(
237320
const rules = parseEventRules(task?.event_rules);
238321
if (rules.length === 0) return { notifyChannels: [], forceFanOut: false };
239322

323+
// Resolve aggregates against the durable high-water mark so ceiling rules
324+
// survive the per-session SDK cost/turn reset (#230). ``ctx.aggregateState``
325+
// (the caller's live reading) is the fallback when no durable value applies.
326+
const aggregateState = await persistAndResolveAggregate(event.task_id, event, task)
327+
?? ctx.aggregateState;
328+
240329
const matched = matchEventRules(rules, event, {
241330
evaluation: 'async',
242-
aggregateState: ctx.aggregateState,
331+
aggregateState,
243332
});
244333
const notifyChannels: string[] = [];
245334
let forceFanOut = false;

cdk/src/handlers/shared/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,18 @@ export interface TaskRecord {
121121
readonly started_at?: string;
122122
readonly completed_at?: string;
123123
readonly cost_usd?: number;
124+
/**
125+
* Durable high-water marks for event-governance aggregate rules (#230).
126+
* The FanOut stream consumer bumps these monotonically as ``agent_cost_update``
127+
* / ``agent_turn`` events flow, so ``cost_usd_gte`` / ``turn_count_gte`` rules
128+
* survive container restarts (the per-session SDK total resets; these do not).
129+
* ponytail: monotonic max of session totals, not a cross-session SUM — a
130+
* multi-session task's ceiling reflects its largest single session. Sum
131+
* accounting (seed baseline from here on the agent restart path) is the
132+
* upgrade if PR-fix retries must accrue.
133+
*/
134+
readonly gov_cumulative_cost_usd?: number;
135+
readonly gov_cumulative_turn_count?: number;
124136
readonly duration_s?: number;
125137
readonly build_passed?: boolean;
126138
/** Whether the post-run lint gate passed (#515). Written with `build_passed`

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

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,84 @@ describe('event-governance-async module', () => {
8484
expect(result1.forceFanOut).toBe(true);
8585
});
8686

87-
test('evaluateAsyncEventRules executes cancel_task action in enforce mode', async () => {
87+
test('bumps durable cost high-water mark and evaluates against it', async () => {
88+
// No cumulative value on the event itself; the ceiling must be met from
89+
// the persisted high-water mark alone (the cross-restart case).
90+
mockSend
91+
.mockResolvedValueOnce({}) // high-water UpdateCommand
92+
.mockResolvedValueOnce({}) // idempotency claim
93+
.mockResolvedValueOnce({}) // Put policy_decision
94+
.mockResolvedValueOnce({}) // Update cancel
95+
.mockResolvedValueOnce({}); // Put task_cancelled
96+
const mod = await import('../../../src/handlers/shared/event-governance-async');
97+
await mod.evaluateAsyncEventRules(
98+
{
99+
task_id: 't3',
100+
event_id: 'e3',
101+
event_type: 'agent_cost_update',
102+
metadata: { cost_usd: 5 }, // this session is cheap...
103+
},
104+
{
105+
task: {
106+
task_id: 't3',
107+
status: 'RUNNING',
108+
gov_cumulative_cost_usd: 40, // ...but a prior session already crossed
109+
event_rules: [
110+
{
111+
id: 'cap',
112+
on: 'agent_cost_update',
113+
when: { aggregate: { cost_usd_gte: 25 } },
114+
action: 'cancel_task',
115+
mode: 'enforce',
116+
evaluation: 'async',
117+
},
118+
],
119+
} as any,
120+
},
121+
);
122+
// First send is the high-water UpdateCommand with a climb-only condition.
123+
const firstCall = mockSend.mock.calls[0][0];
124+
expect(firstCall._type).toBe('Update');
125+
expect(firstCall.input.ConditionExpression).toContain('gov_cumulative_cost_usd < :c');
126+
// The cancel fired — resolved aggregate (max(40, 5)=40) still ≥ 25.
127+
const cancelUpdate = mockSend.mock.calls.find(
128+
(c) => c[0]._type === 'Update' && String(c[0].input.UpdateExpression).includes('#status'),
129+
);
130+
expect(cancelUpdate).toBeDefined();
131+
});
132+
133+
test('turn_count_gte rule fires on turn_count metadata', async () => {
134+
mockSend.mockResolvedValue({});
135+
const mod = await import('../../../src/handlers/shared/event-governance-async');
136+
const result = await mod.evaluateAsyncEventRules(
137+
{
138+
task_id: 't4',
139+
event_id: 'e4',
140+
event_type: 'agent_turn',
141+
metadata: { turn_count: 35 },
142+
},
143+
{
144+
task: {
145+
task_id: 't4',
146+
status: 'RUNNING',
147+
event_rules: [
148+
{
149+
id: 'turns',
150+
on: 'agent_turn',
151+
when: { aggregate: { turn_count_gte: 30 } },
152+
action: 'escalate',
153+
mode: 'enforce',
154+
evaluation: 'async',
155+
notify_channels: ['slack'],
156+
},
157+
],
158+
} as any,
159+
},
160+
);
161+
expect(result.notifyChannels).toContain('slack');
162+
});
163+
164+
test('executes cancel_task action in enforce mode', async () => {
88165
mockSend
89166
.mockResolvedValueOnce({}) // idempotency
90167
.mockResolvedValueOnce({}) // Put policy_decision

docs/design/EVENT_GOVERNANCE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ Async `require_approval` after `pr_created` must state that the PR already exist
152152
## 9. Out of scope
153153

154154
- Replacing tool Cedar with event rules
155+
- Cedar-on-events (rejected — see §10; declarative matchers are permanent)
155156
- Stream-only HITL (race with fast agents)
156157
- EventBridge as primary internal bus
157158
- Separate approve commands for event vs tool gates
@@ -162,6 +163,6 @@ Async `require_approval` after `pr_created` must state that the PR already exist
162163

163164
| Question | Resolution |
164165
|----------|------------|
165-
| Rule language | Declarative field matchers (Phase 0–1); Cedar-on-events deferred to ADR |
166+
| Rule language | **Resolved: declarative field matchers are the permanent language; Cedar-on-events is rejected.** Cedar is an *authorization* language (`principal-action-resource-context` → permit/forbid) with no aggregation — it cannot express `cost_usd_gte` / `turn_count_gte`, and event actions (`notify`/`escalate`/`cancel_task`/`inject_nudge`) are ECA automation, not authorization decisions. A Cedar port would still need a bespoke action layer, and it would add a third Cedar runtime alongside the two we already must bump in lockstep (`cedar-wasm`, `cedarpy`) for zero gain. The two-plane split stands: Cedar governs tool execution (fail-closed); the declarative matcher governs events. The matcher already has cross-language parity, schema validation, and fixture parity tests. |
166167
| Checkpoint trust | Pipeline-emitted only |
167168
| Scope algebra (tool + event overlap) | Idempotency key; document in runbooks |

docs/src/content/docs/architecture/Event-governance.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ Async `require_approval` after `pr_created` must state that the PR already exist
156156
## 9. Out of scope
157157

158158
- Replacing tool Cedar with event rules
159+
- Cedar-on-events (rejected — see §10; declarative matchers are permanent)
159160
- Stream-only HITL (race with fast agents)
160161
- EventBridge as primary internal bus
161162
- Separate approve commands for event vs tool gates
@@ -166,6 +167,6 @@ Async `require_approval` after `pr_created` must state that the PR already exist
166167

167168
| Question | Resolution |
168169
|----------|------------|
169-
| Rule language | Declarative field matchers (Phase 0–1); Cedar-on-events deferred to ADR |
170+
| Rule language | **Resolved: declarative field matchers are the permanent language; Cedar-on-events is rejected.** Cedar is an *authorization* language (`principal-action-resource-context` → permit/forbid) with no aggregation — it cannot express `cost_usd_gte` / `turn_count_gte`, and event actions (`notify`/`escalate`/`cancel_task`/`inject_nudge`) are ECA automation, not authorization decisions. A Cedar port would still need a bespoke action layer, and it would add a third Cedar runtime alongside the two we already must bump in lockstep (`cedar-wasm`, `cedarpy`) for zero gain. The two-plane split stands: Cedar governs tool execution (fail-closed); the declarative matcher governs events. The matcher already has cross-language parity, schema validation, and fixture parity tests. |
170171
| Checkpoint trust | Pipeline-emitted only |
171172
| Scope algebra (tool + event overlap) | Idempotency key; document in runbooks |

0 commit comments

Comments
 (0)