Product limits: scoped quotas and usage metering

How Refract enforces quotas without letting billing logic leak into application code.

Sooner or later every SaaS hits the same wall: you stop asking whether a user can do something and start asking how much they can do.

RBAC answers "can this role upload videos?" Quotas answer something harder: how many uploads does this org get this billing cycle? Is that limit shared across the workspace or isolated per seat? What happens when a customer downgrades mid-cycle?

Those questions are easy to underestimate. The first implementation is always a counter column:

videos_created_this_month INTEGER

And for a while, that works. Then plans diverge. Then you add add-ons. Then one role should bypass metering. Then a billing-cycle reset deletes the wrong counters. Then a webhook retries and double-counts usage. Then a downgrade creates an impossible state where used > max.

At that point the problem is no longer tracking a number. The problem is ownership: where does policy live, who enforces it, what resets usage, and how do handlers avoid knowing billing internals?

Here's how Refract solves that.


Permissions vs quotas

Two questions look similar but belong to completely different systems.

QuestionSystem
Can this member perform this action at all?Scopes / RBAC
How many times can they perform it?Product limits

Scopes are binary authorization. Limits are usage metering. The failure modes are different, so keep them separate.

If a feature should not exist for a role, use scopes. If the role has access but consumption must be capped, use product limits. Using a max_value: 0 row to hide a feature is an anti-pattern: the feature should be removed through RBAC, not papered over with a quota.


The design goal

The implementation was built around four constraints:

  1. Policy lives in the catalog, not in handlers
  2. Enforcement happens through one API
  3. Usage accounting is concurrency-safe
  4. Limits evolve without rewriting application code

That last point matters most. Most quota systems work for v1 pricing. The hard part is surviving per-seat plans, pooled org quotas, add-ons, grandfathered products, billing-cycle resets, retries, mid-cycle downgrades, and pricing models nobody has invented yet.


Three states per role

For any limit key (e.g. videos.create), a membership role always resolves to one of three states:

StateConfigurationRuntime behavior
BypassNo matching limit row existsAction executes; usage is not metered
LimitedRow with max_value > 0Usage enforced against the ceiling
BlockedRow with max_value = 0Positive consumption rejected

One row, "each admin, 10 per billing cycle," puts admins in Limited. Members and guests are in Bypass unless you add rows for them. Add a member row with max_value: 0 and they transition from Bypass to Blocked. No handler changes, no branching logic, no migrations.

Org-wide rows (role unset) and all rows apply to every membership automatically. Once one exists, nobody bypasses that key anymore.


What you configure on the product

Each limit row on a plan picks three things.

Reset period

  • billing_cycle: usage resets with the subscription period; usage rows are scoped to cycle start and end so historical periods coexist safely
  • none: all-time cap, no period columns; useful for workspace caps, lifetime imports, fixed entitlement pools

Audience

  • Org-wide shared pool: one counter per org, not per member
  • Per-membership (all, admin, member, or guest): each member gets an isolated counter

Allowance

  • max_value > 0: limited to that amount per period
  • max_value: 0: blocked for metered roles

Multiple products can define the same limit key. When a member has both a base plan and an add-on touching the same key, the highest max_value wins. That allows additive upgrades without mutating the original plan.


Published products are immutable

One rule prevents an enormous amount of operational pain: published product limits are read-only.

Customers should never wake up to silently modified quota rules because somebody edited a row in production. If you need new limits, publish a new product version and migrate customers intentionally. That makes quota behavior reproducible and lets you always answer "what limits did this customer actually buy?" — which becomes surprisingly valuable once pricing evolves.


Enforcement through one wrapper

Application code never manipulates counters directly. Handlers call one function:

const result = await withProductLimit(
  {
    organizationMember,
    limitKey: AllLimitKeys.VIDEOS_CREATE,
    delta: 1,
    uniqueIdentifier: `video:${videoId}`,
    fn: () => tools.rds.models.Video.create({ /* ... */ }),
  },
  tools,
);

That wrapper owns policy resolution, quota checks, usage increments, idempotency, concurrency safety, and decrement handling. Handlers never touch billing state directly. That separation is the entire point.

delta: 1 reserves quota before fn runs. If used + delta would exceed max_value, the action never executes and the wrapper returns a customer-safe failure reason.

delta: -1 releases usage for refunds, deletes, or rollbacks. Decrements skip ceiling checks and floor at zero, keeping usage accounting reversible.

uniqueIdentifier is optional but important in distributed systems. When set, the wrapper remembers that call for seven days. If the same operation is retried (network hiccup, double-submit, webhook replay), it returns success without running the work twice or counting the usage again.

Row locks on organization_limit_usages keep concurrent increments honest: the quota check and increment happen atomically, so two simultaneous requests can't both read used = 9 against a max = 10 and both succeed.

Super admins bypass limits entirely, keeping internal tooling separate from customer metering.


Downgrades preserve overages

When a downgrade leaves used > max, Refract does not silently clamp usage. The overage stays visible and future consumption stays blocked until usage drops or the billing period resets. Retroactively rewriting history to paper over the overage would break accounting integrity.


When counters reset

Resets happen out-of-band through queue consumers, not inline on the increment path.

TriggerConsumer
Billing cycle rollovercycle_reset
Organization deletionorg_purge
Membership deletionmember_purge

Role changes do not purge usage. A title change shouldn't reset consumption history: enforcement looks at the current role, current policy, and current usage without rewriting the past.

The limit definition cache (limits:product:{productId}) invalidates when catalog limits change so runtime policy stays current. Usage itself is never read from cache; enforcement always hits durable storage.


What the frontend gets

me.memberships.limits exposes maxValue, used, remaining, limitScope (org_wide | per_membership), and resetPeriod. The server already resolved which rows apply, merged products, and determined each key's effective state. The frontend never recomputes policy.

Keys in Bypass state are omitted entirely. The absence of a key means "this limit does not apply" — not "infinite quota."


Why this holds together

The interesting part of quota systems is not counting. It is containment.

ConcernOwner
AuthorizationRBAC / scopes
Pricing policyProduct catalog
Usage accountingLimit usage store
EnforcementwithProductLimit
ResetsQueue consumers
PresentationResolved API state

Each concern has one owner. New plans, add-ons, and pricing experiments can evolve without touching handlers. That durability is the whole point.


The short version

  • Scopes answer "can they?" Limits answer "how many?"
  • Every role resolves to Bypass, Limited, or Blocked
  • Policy is immutable after publishing
  • Enforcement happens through one wrapper
  • Usage accounting is concurrency-safe and idempotent
  • Resets happen asynchronously through queue consumers
  • Handlers never manipulate billing state directly

Shipped May 29, 2026. See the changelog for release notes and the billing architecture guide for schemas, queues, and extension patterns.

FAQ

What's the difference between RBAC scopes and product limits?

Scopes answer whether a role can perform an action at all, while product limits answer how many times they can perform it, and the two are deliberately kept as separate systems.

What are the three states a role can be in for a usage limit?

Bypass (unmetered, no matching row), Limited (usage enforced against a ceiling), or Blocked (max_value set to zero, action rejected).

Why are published product limits immutable?

So customers never experience silently changed quota rules, and so pricing evolution stays reproducible by publishing new product versions instead of editing rows in production.

How does Refract prevent double-counting usage on retries?

The uniqueIdentifier parameter lets the enforcement wrapper recognize a repeated call within seven days and return success without re-running the work or re-counting usage.

What happens to usage when a customer downgrades mid-cycle?

Overages stay visible instead of being silently clamped, and further consumption is blocked until usage drops or the billing period resets.