← All posts
Building5 min read

A quota meter your customer can reset

Our plan limits were computed live from a table the customer had delete rights on — and the product's own "clear history" button exercised those rights. The fix is a monotonic counter, not a policy.

Our plan limits meter usage per billing period — logged calls, optimised spend. The meter was computed the obvious way: count the rows.

select count(*) from calls
where user_id = $1 and created_at >= period_start

Clean, simple, always in sync with the data. And resettable by the customer, because the customer can delete the rows it counts. Not through some injection exploit — through a supported, documented, deliberately built feature: Settings → Clear history, which erases your call telemetry. A privacy-respecting product must offer that. Ours did. And since the quota meter was a live aggregate over the same table, clearing your history also cleared your usage meter. Hit the cap, clear, continue — free usage beyond any plan limit, via a button we built, on purpose, for a different reason.

The conflict is structural, not a bug in either feature

Neither feature is wrong. Telemetry should be deletable — data-minimisation rights are not optional, and we found out the hard way what shipping broken erasure costs. Quota should reflect usage. The defect is one table serving two masters: rows as telemetry belong to the customer and must be erasable; rows as billing evidence belong to the ledger and must not be. Any system that computes billable usage live from customer-deletable data has this hole, whatever its policy documents say — retention policies, TTLs and "please don't" are conventions, and the delete button does not read conventions.

The fix: a counter that only goes up

Usage is now its own tiny table — one row per user per period — maintained by a database trigger on insert into calls:

calls row inserted   → counter += 1   (and spend, when the call was optimised)
calls rows deleted   → counter unchanged

The counter is monotonic within a period: inserts increment it, and nothing the customer can reach decrements it. Clear your history and the telemetry genuinely disappears — the detail rows are gone, which is what the privacy feature promises — while the count of what happened this period survives, because "how much service was consumed" is a fact about the period, not a row of personal telemetry. The two masters get two tables, and each table has exactly one write authority.

Three implementation notes that carried the real lessons:

The trigger lives in the database, not the application. An application-level counter has a bypass for every code path that forgets it, including next year's. A trigger fires on the insert itself, whoever performs it. The delete path needs no special handling at all — which is the point: correctness by construction, not by every future author remembering.

Degraded paths must still meter. Past the logged-calls cap the proxy stops inserting call rows (logging sheds; the request itself is never touched — the same never-break-the-request rule that governs every failure path in the proxy). But no insert means no trigger, which means running past the cap would stop the meter — the exact inversion the fix exists to prevent, arrived at from the other side. Shed calls therefore increment the counter through their own path. When you move metering off the data, you must find every place the data was already not being written.

Deciding what deletion means, case by case. Full account erasure removes the counter — the person is leaving, and the row is keyed to them. Clear-history keeps it — the telemetry is leaving, the subscription relationship is not. Same table, two deletion semantics, and collapsing them either breaks a legal right or re-opens the reset. The billing questions this distinction feeds — what happens at the cap, what overage costs — are on the pricing page, and the enforcement direction ("Vigil does less, your request never fails") is FAQ material; this post is about keeping the meter those answers depend on.

The general test

Ask of your own system: for each number that gates money or limits, who can make it go down, and is that list exactly who you intend? Live aggregates inherit the delete permissions of whatever they aggregate — that is the whole trap, restated. It has cousins everywhere: invoice line items computed from mutable event tables, rate limits counted from prunable logs, "storage used" summed from files the user can remove after the bill was computed on them. Every one is a meter someone else can wind back. Billing-grade numbers want append-only sources — a counter, a ledger table with no delete grant, an immutable event stream — with deletion rights held by the billing system alone, on its own schedule.

What to do

Find every quantity in your product that feeds billing or enforcement, and for each one trace what it is computed from and who holds delete or update rights on that source — including your own features, not just your API surface. Anything computed live from customer-mutable data is resettable by the customer, whatever your policies say; move it to a monotonic counter maintained at the database layer, and then walk every path that skips the normal write — batch imports, sampling, load-shedding — and make sure each one still meters, because a meter that stops exactly when usage exceeds the cap is the failure wearing its own fix as a costume.