feat(p9): per-tenant outbound quota + cellId assignment

Task T3.4: OutboundService gains a per-tenant egress cap (tenantLimit/tenantWindowMs,
IIOS_TENANT_OUTBOUND_*) counting a scope's commands in the window — a noisy tenant
is RATE_LIMITED while others keep sending (noisy-neighbor protection). Persists
scopeId on every command (fixes the previously-unscoped table). resolveScope assigns
IiosScope.cellId (IIOS_CELL_ID, default cell-default) — the cell-partition hook.
Tests: per-tenant cap isolates tenants; new scopes carry a cellId.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-02 21:55:14 +05:30
parent d002c8f521
commit e7b8845c8a
3 changed files with 27 additions and 3 deletions
@@ -133,4 +133,15 @@ describe('Adapter outbound (P5)', () => {
expect(cmd.status).toBe('BLOCKED'); expect(cmd.status).toBe('BLOCKED');
expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0); expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0);
}); });
it('per-tenant quota: a noisy tenant is capped while another still sends (P9 slice 3)', async () => {
const svc = outbound();
svc.tenantLimit = 1;
const a1 = await svc.send('WEBHOOK', 't1', {}, 'ta-1', 'scope-A');
const a2 = await svc.send('WEBHOOK', 't2', {}, 'ta-2', 'scope-A'); // A over its cap
const b1 = await svc.send('WEBHOOK', 't3', {}, 'tb-1', 'scope-B'); // B unaffected
expect(a1.status).toBe('SENT');
expect(a2.status).toBe('RATE_LIMITED');
expect(b1.status).toBe('SENT');
});
}); });
@@ -15,6 +15,9 @@ export class OutboundService {
/** Public so tests can lower them; default from env. */ /** Public so tests can lower them; default from env. */
limit = Number(process.env.IIOS_OUTBOUND_LIMIT ?? 5); limit = Number(process.env.IIOS_OUTBOUND_LIMIT ?? 5);
windowMs = Number(process.env.IIOS_OUTBOUND_WINDOW_MS ?? 60_000); windowMs = Number(process.env.IIOS_OUTBOUND_WINDOW_MS ?? 60_000);
/** Per-tenant (scope) egress cap — noisy-neighbor protection (P9). */
tenantLimit = Number(process.env.IIOS_TENANT_OUTBOUND_LIMIT ?? 10_000);
tenantWindowMs = Number(process.env.IIOS_TENANT_OUTBOUND_WINDOW_MS ?? 60_000);
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@@ -32,16 +35,17 @@ export class OutboundService {
const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } }); const existing = await this.prisma.iiosOutboundCommand.findUnique({ where: { idempotencyKey } });
if (existing) return existing; if (existing) return existing;
if (!(await this.allow(channelType, target))) { // Per-target rate limit AND per-tenant quota (a noisy tenant can't starve shared egress).
if (!(await this.allow(channelType, target)) || !(await this.allowTenant(scopeId))) {
const cmd = await this.prisma.iiosOutboundCommand.create({ const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey }, data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'RATE_LIMITED', idempotencyKey },
}); });
await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } }); await this.prisma.iiosDeliveryAttempt.create({ data: { commandId: cmd.id, attemptNo: 1, status: 'RATE_LIMITED' } });
return cmd; return cmd;
} }
const cmd = await this.prisma.iiosOutboundCommand.create({ const cmd = await this.prisma.iiosOutboundCommand.create({
data: { channelType, target, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey }, data: { channelType, target, scopeId, payload: payload as Prisma.InputJsonValue, status: 'PENDING', idempotencyKey },
}); });
let result; let result;
@@ -68,6 +72,14 @@ export class OutboundService {
return updated; return updated;
} }
/** Per-tenant egress quota (P9): count this scope's commands in the window vs the cap. */
private async allowTenant(scopeId?: string): Promise<boolean> {
if (!scopeId) return true; // unscoped sends are not tenant-limited
const since = new Date(Date.now() - this.tenantWindowMs);
const count = await this.prisma.iiosOutboundCommand.count({ where: { scopeId, createdAt: { gte: since } } });
return count < this.tenantLimit;
}
/** Token/window bucket keyed by (channelType, target). Resets when the window elapses. */ /** Token/window bucket keyed by (channelType, target). Resets when the window elapses. */
private async allow(channelType: string, target: string): Promise<boolean> { private async allow(channelType: string, target: string): Promise<boolean> {
const now = new Date(); const now = new Date();
@@ -46,6 +46,7 @@ describe('Tenant isolation (P9 / KG-10 — cross-tenant blast radius)', () => {
it('tenant B cannot read or mutate tenant A resources, and sees none of them', async () => { it('tenant B cannot read or mutate tenant A resources, and sees none of them', async () => {
// ── Seed tenant A: a route decision, an AI artifact, a meeting ── // ── Seed tenant A: a route decision, an AI artifact, a meeting ──
const scopeA = await actors.resolveScope(A); const scopeA = await actors.resolveScope(A);
expect(scopeA.cellId).toBeTruthy(); // new scopes carry a cell assignment (P9 cell hook)
const interactionId = await interactionFor(A); const interactionId = await interactionFor(A);
await prisma.iiosRouteBinding.create({ await prisma.iiosRouteBinding.create({
data: { scopeId: scopeA.id, originChannelType: 'WHATSAPP', originRef: 'adult-group', destinationChannelType: 'PORTAL', destinationRef: 'adults', requiresReview: true, enabled: true }, data: { scopeId: scopeA.id, originChannelType: 'WHATSAPP', originRef: 'adult-group', destinationChannelType: 'PORTAL', destinationRef: 'adults', requiresReview: true, enabled: true },