Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/cloud/src/account/organization-limits.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest";

import {
FREE_ORGANIZATIONS_PER_USER_LIMIT,
hasNonFreeOrganizationSubscription,
hasPaidOrganizationSubscription,
isOverFreeOrganizationLimit,
shouldApplyFreeOrganizationLimit,
Expand All @@ -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" },
Expand Down
12 changes: 7 additions & 5 deletions apps/cloud/src/engine/execution-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 11 additions & 10 deletions apps/cloud/src/engine/execution-stack-metered.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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.
Expand All @@ -50,16 +50,17 @@ export const CloudMeteringEngineDecorator: Layer.Layer<EngineDecorator, never, A
const balanceGate = makeExecutionLimitGate((organizationId) =>
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 {
Expand Down
25 changes: 25 additions & 0 deletions apps/cloud/src/extensions/billing/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,31 @@ export const hasPaidOrganizationSubscription = (
subscriptions: ReadonlyArray<OrganizationLimitSubscriptionSummary>,
): 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<OrganizationLimitSubscriptionSummary>,
): 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<OrganizationLimitMembershipSummary>,
paidOrganizationIds: ReadonlySet<string>,
Expand Down
Loading