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