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