0006 — counter: a feature kind for "measured, never billed"
View Source
- Status: Accepted
- Date: 2026-09-11
Context
A product that bills from the prepaid credit ledger (ADR 0005) still wants the request count on its dashboard. Until 0.5 the only way to say "count this and never block it" was:
metered :requests, included: 0, unit_price: 0That expression is a lie in three places, and every one of them surfaces in the UI:
quota/2reportsincluded: 0andoverage: used. Every single request is overage, so a dashboard renders6 / 0and "6 over the included allowance".percent/2divides by the included allowance. Withincluded: 0the guard clause returns0, so a bar renders at 0% forever — a bar that is both meaningless and wrong, since there is no denominator to be 0% of.unit_price: 0says "billed at zero", not "not billed". Pro's dashboard reads a metered feature as a subscription-overage line and adds "billed at period end". For a prepaid product nothing is billed at period end; the money already left the ledger when the request ran.
The cost of leaving it alone is that every host has to special-case
included == 0 in its own renderer, and the special case is invisible — it
looks like a working configuration until a customer reads the dashboard.
Options considered
- Keep
metered(included: 0, unit_price: 0)and fix the renderers. The plan declaration still says "metered"; every current and future renderer (core components, Pro dashboard, each host's own UI) has to independently rediscover theincluded == 0convention. A convention that must be re-derived at every call site is not a convention. limit :requests, :infinity, :hard. Reuses a kind, but an infinite hard cap is a cap that never fires, andremaining/2would have to invent:infinity - used. The:hardmachinery (the compare-and-roll-back inCounter.reserve/5) would run on every request for nothing.- A
:softlimit mode. A third mode onlimit/3implies a number, and a counter has none. meteredwithunit_price: nilmeaning "not billed". Cheaper to add, but keeps theincludeddenominator and so keeps the6 / 0bar. It also makesniland0mean different things on the same field.- A new
counter/1kind. One more arm in the DSL, the feature-config type,check/2,quota/2and the renderers.
Decision
(5) A new feature kind, counter(:feature) → {:counter}.
check/2is:ok,entitled?/2istrue,remaining/2is:unlimited, andreserve/3admits unconditionally while still incrementing the counter — a counter that stopped counting under load would be worse than useless.quota/2returnskind: :counterwithlimit: nil,included: nilandpercent: nil, andoverage: 0.
percent: nil is the load-bearing part of this ADR. 0 would be a number a
renderer can draw, and it would draw a bar that says "you have used 0% of your
allowance" to a customer who has no allowance. nil is not drawable, so a
renderer has to decide what to do, and the only correct decision — no bar — is
also the easy one. The same argument applies to limit and included: nil
cannot be formatted into "6 / 0" by accident.
Every clause in this package that matches on a feature config or a quota kind
has an explicit counter arm, including AuroraMeter.Components.usage_meter/1,
which renders the bare count and no role="progressbar" element at all. A
missing clause here is a FunctionClauseError in a host's dashboard, which is
why the arms are explicit rather than relying on a catch-all.
Consequences
- Hosts that were expressing a counter as
metered(included: 0, unit_price: 0)should switch. Nothing breaks if they do not — the metered semantics are unchanged for products that genuinely bill overage — but they keep the misleading wording. quota/2'spercentwas alreadyinteger() | nil(nilfor boolean and integer features and undeclared ones), so callers that already handlednilneed no change. A caller that assumedpercentwas non-nil wheneverusedwas meaningful now has one more case, and that case was always latent.- A counter is not a billing primitive. It deliberately carries no price, and
Pro will not report it to Stripe. Money for a counted resource lives in the
credit ledger, and the money series (
AuroraMeter.Credits.spend_history/2) is what a credit-billed dashboard should chart. - Adding a kind is a one-way door for the
feature_configtype: every future consumer must handle{:counter}. That is the point — the type is where the compiler can tell a host it has missed a case.