diff --git a/apps/cloud/src/account/organization-limits.node.test.ts b/apps/cloud/src/account/organization-limits.node.test.ts index f957a314b2..502c96a0fe 100644 --- a/apps/cloud/src/account/organization-limits.node.test.ts +++ b/apps/cloud/src/account/organization-limits.node.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; import { FREE_ORGANIZATIONS_PER_USER_LIMIT, + hasNonFreeOrganizationSubscription, hasPaidOrganizationSubscription, isOverFreeOrganizationLimit, shouldApplyFreeOrganizationLimit, @@ -22,6 +23,37 @@ describe("organization limits", () => { expect(hasPaidOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false); }); + it("exempts any active non-free subscription from the execution rate limit", () => { + expect(hasNonFreeOrganizationSubscription([{ planId: "team", status: "active" }])).toBe(true); + expect(hasNonFreeOrganizationSubscription([{ planId: "enterprise", status: "trialing" }])).toBe( + true, + ); + // Grandfathered and pay-as-you-go plans are not in the paid set, but they + // are not Free either — the backstop must never cap them. + expect(hasNonFreeOrganizationSubscription([{ planId: "professional", status: "active" }])).toBe( + true, + ); + expect(hasNonFreeOrganizationSubscription([{ planId: "hobby", status: "active" }])).toBe(true); + expect( + hasNonFreeOrganizationSubscription([{ planId: "free-pay-as-you-go", status: "active" }]), + ).toBe(true); + expect( + hasNonFreeOrganizationSubscription([ + { planId: "free", status: "active" }, + { planId: "team", status: "active" }, + ]), + ).toBe(true); + }); + + it("rate-limits orgs that hold nothing but Free or inactive subscriptions", () => { + expect(hasNonFreeOrganizationSubscription([])).toBe(false); + expect(hasNonFreeOrganizationSubscription([{ planId: "free", status: "active" }])).toBe(false); + expect(hasNonFreeOrganizationSubscription([{ planId: "team", status: "canceled" }])).toBe( + false, + ); + expect(hasNonFreeOrganizationSubscription([{ planId: null, status: "active" }])).toBe(false); + }); + it("applies the free org limit only when none of the user's active orgs are paid", () => { const activeMemberships = [ { organizationId: "org_free_1", status: "active" }, diff --git a/apps/cloud/src/engine/execution-rate-limit.ts b/apps/cloud/src/engine/execution-rate-limit.ts index 03f5d75b28..04c35f5774 100644 --- a/apps/cloud/src/engine/execution-rate-limit.ts +++ b/apps/cloud/src/engine/execution-rate-limit.ts @@ -8,11 +8,13 @@ // each MCP session lives in its own DO instance, so an in-memory counter // would be per-session and trivially bypassed by opening more sessions). // -// Paid organizations are EXEMPT. The cap was sized for free-tier abuse but -// applied to everyone, and on 2026-08-18 it blocked a paying customer -// mid-workload — their agent gave up on Executor and routed around it. Paid -// usage is what the balance gate and metered overage are for; this backstop -// has no business capping it. +// Every organization on a plan other than Free is EXEMPT. The cap was sized +// for free-tier abuse but applied to everyone, and on 2026-08-18 it blocked a +// paying customer mid-workload — their agent gave up on Executor and routed +// around it. The first fix exempted only the plans sold today, and on +// 2026-09-03 that capped an org on a grandfathered plan. Non-free usage is +// what the balance gate and metered overage are for; this backstop has no +// business capping it. // // The exemption is resolved ONLY once the counter reports an org over the cap, // so the common path (under the cap) costs the counter increment and nothing diff --git a/apps/cloud/src/engine/execution-stack-metered.ts b/apps/cloud/src/engine/execution-stack-metered.ts index 6063e1456c..f3a66dd228 100644 --- a/apps/cloud/src/engine/execution-stack-metered.ts +++ b/apps/cloud/src/engine/execution-stack-metered.ts @@ -26,7 +26,7 @@ import { } from "@executor-js/api/server"; import { AutumnService } from "../extensions/billing/service"; -import { hasPaidOrganizationSubscription } from "../extensions/billing/plans"; +import { hasNonFreeOrganizationSubscription } from "../extensions/billing/plans"; import type { DbService } from "../db/db"; import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; import { makeExecutionLimitGate } from "./execution-gate"; @@ -35,8 +35,8 @@ import { withExecutionUsageTracking } from "./execution-usage"; // Usage-metering decorator bound to the billing service, plus the two // pre-execution guards this layer owns, ordered cheapest first: -// 1. rate-limit backstop (counter DO; free-tier abuse only — paid orgs are -// exempt via the subscription lookup below) +// 1. rate-limit backstop (counter DO; free-tier abuse only — any org on a +// plan other than Free is exempt via the subscription lookup below) // 2. execution balance gate (Autumn check, cached 60s, fails open) // 3. usage tracking — fire-and-forget (`Effect.runFork`) so the billing // call can't stall a user-facing execution. @@ -50,16 +50,17 @@ export const CloudMeteringEngineDecorator: Layer.Layer autumn.checkExecutionBalance(organizationId), ); - // The limiter's paid-org exemption. This is the billing coupling the - // limiter module deliberately avoids owning, and it reads the same - // `PAID_AUTUMN_PLAN_IDS` config as the org-creation and seat gates so - // "paid" means one thing across the app. The limiter calls this only - // for orgs already over the cap and caches the answer, so the extra - // Autumn round trip stays off the hot path. + // The limiter's exemption. This is the billing coupling the limiter + // module deliberately avoids owning. Any active subscription other than + // Free exempts the org: the backstop is for free-tier abuse, and the + // narrower "is on a plan we sell today" predicate the org-creation gate + // uses capped grandfathered and pay-as-you-go customers. The limiter + // calls this only for orgs already over the cap and caches the answer, + // so the extra Autumn round trip stays off the hot path. const rateLimiter = makeCloudExecutionRateLimiter((organizationId) => Effect.map( autumn.use((client) => client.customers.getOrCreate({ customerId: organizationId })), - (customer) => hasPaidOrganizationSubscription(customer.subscriptions), + (customer) => hasNonFreeOrganizationSubscription(customer.subscriptions), ), ); return { diff --git a/apps/cloud/src/extensions/billing/plans.ts b/apps/cloud/src/extensions/billing/plans.ts index 66c617acf8..d3f1ce2aab 100644 --- a/apps/cloud/src/extensions/billing/plans.ts +++ b/apps/cloud/src/extensions/billing/plans.ts @@ -38,6 +38,31 @@ export const hasPaidOrganizationSubscription = ( subscriptions: ReadonlyArray, ): boolean => subscriptions.some(isPaidOrganizationSubscription); +// --------------------------------------------------------------------------- +// Execution rate-limit exemption. +// +// The hourly execution backstop exists for FREE-tier abuse, so the only orgs +// it may cap are those with nothing but the Free plan. This is deliberately +// NOT `hasPaidOrganizationSubscription`: that set names only the plans sold +// today, and every other plan an org can legitimately hold — grandfathered +// (`hobby`, `professional`), pay-as-you-go, or one added later and not yet +// listed here — fell through to the cap. A paying customer blocked by an +// abuse backstop is the worse failure, so the exemption is "anything but +// Free" rather than "one of the plans we remembered to list". +// --------------------------------------------------------------------------- + +export const FREE_AUTUMN_PLAN_ID = "free"; + +export const hasNonFreeOrganizationSubscription = ( + subscriptions: ReadonlyArray, +): boolean => + subscriptions.some( + (subscription) => + subscription.planId != null && + subscription.planId !== FREE_AUTUMN_PLAN_ID && + ACTIVE_AUTUMN_SUBSCRIPTION_STATUSES.has(subscription.status ?? ""), + ); + export const shouldApplyFreeOrganizationLimit = ( activeMemberships: ReadonlyArray, paidOrganizationIds: ReadonlySet,