Skip to content

Commit 88ceffc

Browse files
sametbasbugclaude
andcommitted
Ask the quota question where the writing happens
Three of the five checks in direct_messages_validate were send quotas: a five second gap, twenty an hour, a hundred a day. They are not answers about a row, they are answers about a moment — whether this agent may send one more right now — and a trigger answers them for every insert, including a restore replaying years of history. The restore already suspends this gate, so nothing was broken. But not having to suspend it is better than suspending it, and the follow limits were never triggers for exactly this reason. The quotas now sit next to them on the write path, checked after idempotent replay so a repeated request is still the same message rather than a new one. What this costs: two genuinely simultaneous requests from one agent can both pass the count. One extra message is not a hole — the quota exists to stop a flood and still does. The trigger keeps the two availability checks. They are cheap and they back up the API's own resolution. The burst limit had a test, but one that passed while the quota was still in the trigger, so it proved nothing about where the rule lives. The hourly and daily limits had no test at all. All three now do, on their own agents and their own time window, and they check that a rejected send writes nothing and does not burn its idempotency key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 735702a commit 88ceffc

3 files changed

Lines changed: 193 additions & 60 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
-- DM gönderim kotaları tetikleyiciden yazma yoluna taşınıyor.
2+
--
3+
-- direct_messages_validate beş şey birden denetliyordu: gönderen uygun mu,
4+
-- alıcı uygun mu, ve üç kota — beş saniyelik ara, saatlik 20, günlük 100.
5+
-- İlk ikisi bir satırın anlamlı olup olmadığını soruyor. Son üçü ise "bu ajan
6+
-- şu an bir mesaj daha gönderebilir mi" diye soruyor ki bu satırın değil
7+
-- eylemin sorusu, ve cevabı yalnız yazma anında geçerli.
8+
--
9+
-- Kotanın burada durmasının somut bedeli yedeklerdi: tetikleyici geri yükleme
10+
-- sırasında da çalışıyor ve geçmiş kendi limitine takılıyor. Geri yükleme
11+
-- artık bu kapıyı askıya alıyor, ama kapıyı askıya almak zorunda kalmamak
12+
-- daha iyi — kota zaten oraya ait değildi. Takip limitleri de aynı sebeple
13+
-- hiç tetikleyici olmadı.
14+
--
15+
-- Kalan iki denetim yerinde: bunlar ucuz ve API'nin kendi kontrolünü
16+
-- yedekliyorlar. Geri yüklemede hâlâ askıya alınıyorlar, çünkü "şu an aktif
17+
-- mi" de bugüne ait bir soru.
18+
19+
DROP TRIGGER direct_messages_validate;
20+
21+
CREATE TRIGGER direct_messages_validate
22+
BEFORE INSERT ON direct_messages
23+
BEGIN
24+
SELECT RAISE(ABORT, 'direct_message_sender_unavailable')
25+
WHERE NOT EXISTS (
26+
SELECT 1 FROM agents
27+
WHERE id = NEW.sender_agent_id
28+
AND status = 'active'
29+
AND onboarding_state = 'active'
30+
);
31+
32+
SELECT RAISE(ABORT, 'direct_message_recipient_unavailable')
33+
WHERE NOT EXISTS (
34+
SELECT 1 FROM agents
35+
WHERE id = NEW.recipient_agent_id
36+
AND status = 'active'
37+
AND onboarding_state = 'active'
38+
);
39+
END;

‎scripts/orbit-slice5-tests.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -689,6 +689,90 @@ describe('Orbit V6 Slice 5 dashboard and platform core', { concurrency: false },
689689
});
690690
});
691691

692+
/* Kotalar tetikleyicide değil yazma yolunda. Burst'ü yukarıdaki test zaten
693+
* görüyordu — ama o test kota tetikleyicideyken de geçiyordu, yani taşımayı
694+
* kanıtlamıyor. Saatlik ve günlük sınırlar ise hiç sınanmamıştı; artık
695+
* benim yazdığım kodda yaşıyorlar. */
696+
test('direct message quotas are enforced on the write path and write nothing when they reject', async () => {
697+
// Kendi ajanları: bu test yüzden fazla mesaj yazıyor ve paylaşılan bir
698+
// gönderenin kutusunu kullansa sonraki testlerin aradığı mesajı ilk
699+
// sayfadan iterdi.
700+
const sender = await seedAgent('slice5-dm-quota-sender');
701+
const recipient = await seedAgent('slice5-dm-quota-recipient');
702+
// Kendi penceresi: başka testlerin mesajları ne saatlik ne günlük sayıma
703+
// karışsın.
704+
const base = NOW + 48 * 60 * 60 * 1000;
705+
const hour = 60 * 60 * 1000;
706+
707+
const send = async (at: number, key: string) => await fetch(`${baseUrl}/v1/direct-messages`, {
708+
method: 'POST',
709+
headers: {
710+
authorization: `Bearer ${sender.token}`,
711+
'content-type': 'application/json',
712+
'x-test-now': String(at),
713+
'idempotency-key': key,
714+
},
715+
body: JSON.stringify({
716+
recipientHandle: recipient.handle,
717+
bodyMarkdown: `Kota penceresi mesajı ${key}.`,
718+
}),
719+
});
720+
const sentCount = async () => {
721+
const response = await fetch(`${baseUrl}/v1/direct-messages?box=sent&limit=50`, {
722+
headers: { authorization: `Bearer ${sender.token}`, 'x-test-now': String(base) },
723+
});
724+
return (await response.json() as { directMessages: unknown[] }).directMessages.length;
725+
};
726+
727+
// Beş saniyelik ara: reddedilen istek hiçbir şey yazmıyor ve kendi
728+
// idempotency anahtarını da tüketmiyor.
729+
assert.equal((await send(base, 'dm-window-1')).status, 201);
730+
const before = await sentCount();
731+
const tooSoon = await send(base + 4_999, 'dm-window-2');
732+
assert.equal(tooSoon.status, 429);
733+
assert.equal((await tooSoon.json() as { error: { code: string } }).error.code, 'direct_message_burst_limited');
734+
assert.equal(await sentCount(), before);
735+
assert.equal((await send(base + 5_000, 'dm-window-2')).status, 201);
736+
737+
// Saatlik sınır. İki mesaj gitti, on sekiz tane daha yirmiye tamamlıyor.
738+
for (let index = 0; index < 18; index += 1) {
739+
const at = base + 10_000 + index * 6_000;
740+
assert.equal((await send(at, `dm-hour-${index}`)).status, 201, `saatlik dolum ${index}`);
741+
}
742+
const hourly = await send(base + 10_000 + 18 * 6_000, 'dm-hour-over');
743+
assert.equal(hourly.status, 429);
744+
const hourlyError = (await hourly.json() as { error: { code: string; details: { quota: unknown } } }).error;
745+
assert.equal(hourlyError.code, 'direct_message_hourly_limit_exceeded');
746+
assert.deepEqual(hourlyError.details.quota, {
747+
key: 'direct_message.send.rolling_hour',
748+
limit: 20,
749+
remaining: 0,
750+
windowSeconds: 3_600,
751+
resetAt: base + hour,
752+
});
753+
754+
// Günlük sınır. İki saat aralıklı bloklar: her blok başında bir önceki
755+
// blok saatlik pencerenin dışında kalıyor, yani yalnız günlük sayım
756+
// birikiyor. Dört blok daha yüze tamamlıyor.
757+
for (let block = 1; block <= 4; block += 1) {
758+
for (let index = 0; index < 20; index += 1) {
759+
const at = base + block * 2 * hour + index * 6_000;
760+
assert.equal((await send(at, `dm-day-${block}-${index}`)).status, 201, `günlük dolum ${block}/${index}`);
761+
}
762+
}
763+
const daily = await send(base + 10 * hour, 'dm-day-over');
764+
assert.equal(daily.status, 429);
765+
const dailyError = (await daily.json() as { error: { code: string; details: { quota: unknown } } }).error;
766+
assert.equal(dailyError.code, 'direct_message_daily_limit_exceeded');
767+
assert.deepEqual(dailyError.details.quota, {
768+
key: 'direct_message.send.rolling_day',
769+
limit: 100,
770+
remaining: 0,
771+
windowSeconds: 86_400,
772+
resetAt: base + 24 * hour,
773+
});
774+
});
775+
692776
test('sponsor witnesses only their own agent and reading changes nothing', async () => {
693777
const sender = agents.get('slice5-equinox')!;
694778
const recipient = agents.get('slice5-external')!;

‎src/server/http/api.ts‎

Lines changed: 70 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2461,61 +2461,79 @@ async function markDirectMessageReadForAgent(
24612461
return json({ directMessage: { id: messageId, readAt } });
24622462
}
24632463

2464-
async function directMessageQuotaApiError(
2464+
/* DM gönderim kotaları.
2465+
*
2466+
* Bunlar bir zamanlar direct_messages üzerinde tetikleyiciydi ve orada
2467+
* durmalarının bedelini yedeklerde ödedik: tetikleyici geri yüklemede de
2468+
* çalışıyor, yani geçmiş kendi limitine takılıyordu. Ama asıl sorun teknik
2469+
* değil, kavramsal — kota "bu satır geçerli mi" sorusunun cevabı değil, "bu
2470+
* ajan şu an bir mesaj daha gönderebilir mi" sorusunun cevabı. O soru yazma
2471+
* anına ait ve yazma yolunda sorulmalı. Takip limitleri de aynı yerde duruyor.
2472+
*
2473+
* Bunun kabul ettiğimiz bedeli şu: aynı ajandan tam eşzamanlı iki istek
2474+
* ikisi de sayımı geçebilir. Fazladan bir mesaj sızması bir güvenlik açığı
2475+
* değil, kotanın amacı sel basmasını önlemek ve onu önlüyor. Karşılığında
2476+
* yedek geri yüklenebiliyor.
2477+
*/
2478+
const DIRECT_MESSAGE_MIN_INTERVAL_MS = 5_000;
2479+
const DIRECT_MESSAGE_LIMIT_PER_HOUR = 20;
2480+
const DIRECT_MESSAGE_LIMIT_PER_DAY = 100;
2481+
2482+
async function requireDirectMessageQuota(
24652483
repository: DirectMessageRepository,
24662484
agentId: string,
24672485
now: number,
2468-
error: unknown,
2469-
): Promise<ApiError | null> {
2470-
const message = error instanceof Error ? error.message : String(error);
2471-
if (!/direct_message_(?:burst|hourly|daily)_limit_exceeded/u.test(message)) {
2472-
return null;
2473-
}
2486+
): Promise<void> {
24742487
const state = await repository.getSendRecoveryState(agentId, now);
2475-
if (message.includes('burst')) {
2476-
return quotaError(
2488+
if (
2489+
state.lastMessageAt !== null
2490+
&& now - state.lastMessageAt < DIRECT_MESSAGE_MIN_INTERVAL_MS
2491+
) {
2492+
throw quotaError(
24772493
now,
24782494
'direct_message_burst_limited',
24792495
'The agent reached a direct-message rate limit.',
24802496
{
24812497
key: 'direct_message.send.minimum_interval',
24822498
limit: 1,
24832499
remaining: 0,
2484-
windowSeconds: 5,
2485-
resetAt: Math.max(now + 1, (state.lastMessageAt ?? now) + 5000),
2500+
windowSeconds: DIRECT_MESSAGE_MIN_INTERVAL_MS / 1000,
2501+
resetAt: Math.max(now + 1, state.lastMessageAt + DIRECT_MESSAGE_MIN_INTERVAL_MS),
24862502
},
24872503
);
24882504
}
2489-
if (message.includes('hourly')) {
2490-
return quotaError(
2505+
if (state.hourlyCount >= DIRECT_MESSAGE_LIMIT_PER_HOUR) {
2506+
throw quotaError(
24912507
now,
24922508
'direct_message_hourly_limit_exceeded',
24932509
'The agent reached a direct-message rate limit.',
24942510
{
24952511
key: 'direct_message.send.rolling_hour',
2496-
limit: 20,
2497-
remaining: Math.max(0, 20 - state.hourlyCount),
2512+
limit: DIRECT_MESSAGE_LIMIT_PER_HOUR,
2513+
remaining: Math.max(0, DIRECT_MESSAGE_LIMIT_PER_HOUR - state.hourlyCount),
24982514
windowSeconds: 60 * 60,
24992515
resetAt: state.oldestHourlyMessageAt === null
25002516
? now + 60 * 60 * 1000
25012517
: state.oldestHourlyMessageAt + 60 * 60 * 1000,
25022518
},
25032519
);
25042520
}
2505-
return quotaError(
2506-
now,
2507-
'direct_message_daily_limit_exceeded',
2508-
'The agent reached a direct-message rate limit.',
2509-
{
2510-
key: 'direct_message.send.rolling_day',
2511-
limit: 100,
2512-
remaining: Math.max(0, 100 - state.dailyCount),
2513-
windowSeconds: 24 * 60 * 60,
2514-
resetAt: state.oldestDailyMessageAt === null
2515-
? now + 24 * 60 * 60 * 1000
2516-
: state.oldestDailyMessageAt + 24 * 60 * 60 * 1000,
2517-
},
2518-
);
2521+
if (state.dailyCount >= DIRECT_MESSAGE_LIMIT_PER_DAY) {
2522+
throw quotaError(
2523+
now,
2524+
'direct_message_daily_limit_exceeded',
2525+
'The agent reached a direct-message rate limit.',
2526+
{
2527+
key: 'direct_message.send.rolling_day',
2528+
limit: DIRECT_MESSAGE_LIMIT_PER_DAY,
2529+
remaining: Math.max(0, DIRECT_MESSAGE_LIMIT_PER_DAY - state.dailyCount),
2530+
windowSeconds: 24 * 60 * 60,
2531+
resetAt: state.oldestDailyMessageAt === null
2532+
? now + 24 * 60 * 60 * 1000
2533+
: state.oldestDailyMessageAt + 24 * 60 * 60 * 1000,
2534+
},
2535+
);
2536+
}
25192537
}
25202538

25212539
async function handleSendDirectMessageForPrincipal(
@@ -2550,6 +2568,10 @@ async function handleSendDirectMessageForPrincipal(
25502568
);
25512569
if (idem.replay) return replayResponse(idem.replay);
25522570
await requireCriticalAnnouncementsRead(platformRepository, principal, now);
2571+
// Kota tekrar oynatmadan sonra bakılıyor: aynı isteğin tekrarı yeni bir
2572+
// mesaj değil, zaten gönderilmiş olanın tekrar söylenmesi. Önce baksaydık
2573+
// her başarılı gönderimin kendi tekrarını beş saniye boyunca reddederdik.
2574+
await requireDirectMessageQuota(directMessageRepository, principal.agentId, now);
25532575

25542576
const message: DirectMessageView = {
25552577
id: createEntityId(),
@@ -2562,37 +2584,25 @@ async function handleSendDirectMessageForPrincipal(
25622584
readAt: null,
25632585
};
25642586
const responseBody = { directMessage: directMessageResponse(message) };
2565-
let concurrentReplay: Response | null;
2566-
try {
2567-
concurrentReplay = await runIdempotentMutation(
2568-
publicationRepository,
2569-
'agent',
2570-
principal.agentId,
2571-
idem.keyDigest,
2572-
idem.requestDigest,
2573-
() => directMessageRepository.sendMessage({
2574-
message,
2575-
idempotency: {
2576-
...idem.row,
2577-
principalType: 'agent',
2578-
principalId: principal.agentId,
2579-
responseStatus: 201,
2580-
responseJson: canonicalJson(responseBody),
2581-
},
2582-
auditEventId: createEntityId(),
2583-
requestId,
2584-
}),
2585-
);
2586-
} catch (error) {
2587-
const mapped = await directMessageQuotaApiError(
2588-
directMessageRepository,
2589-
principal.agentId,
2590-
now,
2591-
error,
2592-
);
2593-
if (mapped) throw mapped;
2594-
throw error;
2595-
}
2587+
const concurrentReplay = await runIdempotentMutation(
2588+
publicationRepository,
2589+
'agent',
2590+
principal.agentId,
2591+
idem.keyDigest,
2592+
idem.requestDigest,
2593+
() => directMessageRepository.sendMessage({
2594+
message,
2595+
idempotency: {
2596+
...idem.row,
2597+
principalType: 'agent',
2598+
principalId: principal.agentId,
2599+
responseStatus: 201,
2600+
responseJson: canonicalJson(responseBody),
2601+
},
2602+
auditEventId: createEntityId(),
2603+
requestId,
2604+
}),
2605+
);
25962606
if (concurrentReplay) return concurrentReplay;
25972607
return idempotentJson(responseBody, 201, idem.row.expiresAt);
25982608
}

0 commit comments

Comments
 (0)