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.
| Question | System |
|---|---|
| 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:
- Policy lives in the catalog, not in handlers
- Enforcement happens through one API
- Usage accounting is concurrency-safe
- 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:
| State | Configuration | Runtime behavior |
|---|---|---|
| Bypass | No matching limit row exists | Action executes; usage is not metered |
| Limited | Row with max_value > 0 | Usage enforced against the ceiling |
| Blocked | Row with max_value = 0 | Positive 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 safelynone: 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, orguest): each member gets an isolated counter
Allowance
max_value > 0: limited to that amount per periodmax_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.
| Trigger | Consumer |
|---|---|
| Billing cycle rollover | cycle_reset |
| Organization deletion | org_purge |
| Membership deletion | member_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.
| Concern | Owner |
|---|---|
| Authorization | RBAC / scopes |
| Pricing policy | Product catalog |
| Usage accounting | Limit usage store |
| Enforcement | withProductLimit |
| Resets | Queue consumers |
| Presentation | Resolved 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.