observe and hold
observe, hold, extend, capture and release live on the bound catalog. Each expands lines (feeds), calls getRate, runs price(), commits and returns a result. To see the plan, call metering.plan.<same call>: same arguments, no commit, returns { result, plan, lines }.
observe
Section titled “observe”Two signatures: one line and many lines.
metering.observe(meter, dims, quantity, ref, ctx, opts?)metering.observe([{ meter, dims, quantity }, …], ref, ctx, opts?)A multi-line observe is one consistent observation: one plan, one commit, all or nothing. quantity is a non-negative integer; the unit is your choice (messages, tokens, minutes, seat-seconds, a transaction amount in micro-USD).
| Option | Meaning | Default |
|---|---|---|
at |
Observation time (epoch ms); passed to getRate and written on usage rows |
Date.now() |
usedSoFar |
Overrides getRate’s; a number for one line, { [meter]: number } for many |
— |
ifMissing |
When getRate returns null: reject (write nothing, no_price) or free (count and write at 0) |
reject |
feeds |
false: skip pool expansion |
true |
Three states
Section titled “Three states”| State | What happens |
|---|---|
| A tariff with price 0 | Free and priced: counted, 0 written to usage, nothing to the ledger |
getRate → null, ifMissing: "reject" |
{ ok: false, reason: "no_price", meter }; nothing written |
getRate → null, ifMissing: "free" |
Counted and written with a zero-priced one-tier tariff |
no_price is a state, not an error: an item whose price is published late (such as WhatsApp window prices) can be queued and retried with the same at (messaging example).
Hold: a value object
Section titled “Hold: a value object”// Reserve: up to 3 SMS segments + 1 verification. The hold is a value; store it (e.g. on the OTP record).const h = await metering.hold( [ { meter: "otp/sms", dims: { country: "TR" }, quantity: 3 }, { meter: "otp/verify", quantity: 1 }, ], { type: "otp_send", id: "otp_1" }, ctx,);if (!h.ok) throw new Error(h.reason);h.upperBound; // 65000 + pool line's worst case (3 × 2000) = 71000h.hold.holdId; // "otp_send:otp_1" → ledger: { op: "hold", amount: 71000, refId: "otp_send:otp_1:hold" }
// Resend: grow the same hold. Existing lines keep their tariff; getRate is asked only for new lines.const e = await metering.extend(h.hold, [{ meter: "otp/sms", dims: { country: "TR" }, quantity: 1 }], ctx);if (!e.ok) throw new Error(e.reason);// ledger: { op: "extend", amount: 22000, refId: "otp_send:otp_1:extend:1" }
// Verified: capture what was sent (pool line follows its feeder), then release the rest.const c = await metering.capture( e.hold, [ { meter: "otp/sms", quantity: 2 }, { meter: "otp/verify", quantity: 1 }, ], ctx,);if (!c.ok) throw new Error(c.reason);c.charged; // 45000 → refIds "otp_send:otp_1:otp/sms:capture:1", "otp_send:otp_1:otp/verify:capture:1", …
const r = await metering.release(c.hold, ctx);// ledger: { op: "release", amount: 93000 − 45000 = 48000, refId: "otp_send:otp_1:release" }- A
holdis a multi-line reservation. Each line’s tariff andusedSoFarare embedded in the hold; the application stores it (e.g. on the OTP record) and the library never reads the ledger. - Every call returns the updated hold (
result.hold, A87). Pass the new value to the next call. Retrying with the same old hold produces the samerefIdand is a no-op incommit. - The reservation is each line’s hold upper bound; pool lines are reserved too. A hold returns
charged: 0: a reservation is not a money movement. - A hold has one line per
(meter, dims)(A101): lines with the same key merge, and a pool line carries its feeders asfeeders[]. The hold rounds each key once: undercumulativeit equals anobservewith separate lines exactly; underper_event_upit differs only by rounding (at most 2 micro-units per line), in either direction. extendgrows the hold: existing(meter, dims)lines grow on their stored tariff,getRateis called only for new lines, and pool lines follow their feeders.capturecharges what actually happened. The line is found bymeter; when the same meter is on several lines,dimsis required. Pool lines are captured along with their feeders. Partial and repeated captures are fine.releasefrees the remaining reservation (upperBound − captured) and closes the hold.
Telescoping captures
Section titled “Telescoping captures”// 1/3 micro-USD per unit. Three captures of 1 unit each cost what one observation of 3 costs.const m = buildMetering().refs(["job"]).meter("gpu/ms").bind(memoryAdapters({ rates: { "gpu/ms": flat(1, 3) } }));const h = await m.hold("gpu/ms", {}, 3, { type: "job", id: "j1" }, { accountId: "acme" });if (!h.ok) throw new Error(h.reason);let hold = h.hold;const charged: number[] = [];for (let i = 0; i < 3; i++) { const c = await m.capture(hold, [{ meter: "gpu/ms", quantity: 1 }], { accountId: "acme" }); if (!c.ok) throw new Error(c.reason); charged.push(c.charged); hold = c.hold;}charged; // [1, 0, 0] — R(u, c+q) − R(u, c), not [1, 1, 1]Partial captures round the hold as one observation: the n-th capture charges R(u, c+q) − R(u, c), where u is the usedSoFar at hold time and c the units captured so far (A88). Otherwise per_event_up would push piecewise captures over the upper bound.
Capture-time tariff
Section titled “Capture-time tariff”const h = await metering.hold("otp/verify", {}, 1, { type: "otp_send", id: "otp_2" }, ctx);if (!h.ok) throw new Error(h.reason);// Default: the tariff stored in the hold (5000). Pass { rate } to price at capture time instead.const c = await metering.capture(h.hold, [{ meter: "otp/verify", quantity: 1 }], ctx, { rate: flat(4_000) });// c.charged → 4000 (must still fit the hold's upper bound, else hold_exceeded)By default a capture uses the tariff stored in the hold and does not call getRate again: an open hold keeps the old price even after a custom price ends (B20). To price at capture time, pass { rate }: one tariff or { [meter]: Rate }. The total still cannot exceed the hold’s upper bound.
hold_exceeded and hold_closed
Section titled “hold_exceeded and hold_closed”if (!r.ok) throw new Error(r.reason);await metering.capture(r.hold, [{ meter: "otp/sms", quantity: 1 }], ctx); // { ok: false, reason: "hold_closed" }await metering.capture(c.hold, [{ meter: "otp/sms", quantity: 3 }], ctx); // { ok: false, reason: "hold_exceeded", meter: "otp/sms" } (2 + 3 > 4)hold_exceeded: a line would capture more units than reserved, or the total amount would exceed the upper bound. hold_closed: the hold was released (A86). Neither writes anything; extend first.
Result reasons
Section titled “Result reasons”Success: { ok: true, charged, lines } (holds and captures add holdId and hold; holds add upperBound). charged is this call’s net money movement: charges, captures and adjustments, not reservations.
reason |
When | Written |
|---|---|---|
no_price |
getRate returned null and ifMissing is reject |
nothing |
usage_required |
The tariff needs usedSoFar and none was given |
nothing |
insufficient_credit |
commit threw InsufficientCredit (also matched by name, so it survives RPC) |
nothing |
quota_exceeded |
usedSoFar + requested > the limit from getRate, or commit threw QuotaExceeded; cause: { meter, limit, used, requested } |
nothing |
invalid_context |
The context failed its schema, accountId is not a string, or the hold belongs to another account |
nothing |
invalid_dims |
The dimensions failed the meter’s schema | nothing |
invalid_rate |
A tariff from getRate or capture { rate } failed validateRate |
nothing |
invalid_ref |
The ref is malformed or ref.type is not in refs() |
nothing |
hold_exceeded |
The capture exceeds the reservation or the upper bound | nothing |
hold_closed |
The hold was already released | nothing |
commit_failed |
commit threw anything else; the error is in cause |
up to commit’s atomicity: should be nothing |
The balance gate
Section titled “The balance gate”“Balance ≥ amount” is not in the library. commit throws InsufficientCredit when short and the library returns insufficient_credit. Holds and extends count as debits too: a prepaid commit refuses a reservation beyond the available balance. Postpaid means commit never throws (B26).
Quotas
Section titled “Quotas”The balance gate answers “can they pay?”; a quota answers “are they allowed?”. Next to rate and usedSoFar, getRate can return a limit for the same period (A107):
const metering = buildMetering() .context<{ accountId: string; plan: "free" | "pro" }>() .refs(["api"]) .meter("api/call", ["route"]) .getRate(async (meter, _dims, ctx, _at) => ({ rate: free, usedSoFar: mem.store.used(ctx.accountId, meter), // this month's calls limit: ctx.plan === "free" ? 10 : undefined, // the same period's cap; 0 = not in the plan })) .commit(mem.commit);
const ctx = { accountId: "acme", plan: "free" as const };await metering.observe("api/call", { route: "/a" }, 9, { type: "api", id: "1" }, ctx); // ok: 9 of 10const r = await metering.observe("api/call", { route: "/a" }, 4, { type: "api", id: "2" }, ctx);// → { ok: false, reason: "quota_exceeded", meter: "api/call",// cause: { meter: "api/call", limit: 10, used: 9, requested: 4 } } nothing written- All or nothing. At 9/10 a request for 4 is refused entirely. Partial use is the app’s call:
cause.limit − cause.usedis what’s left. - Per meter. Lines of the same meter add up, whatever their dims.
limit: 0means the meter is not in the plan (unlikeno_price: priced, but not allowed). - Pools. A limit on a pool meter caps every channel that feeds it (“SMS + WhatsApp 1,000 in total, then stop”).
- Atomic. The library’s early check can’t see two concurrent requests that both read 999. So the limit is written on the usage row and the adapter re-checks it inside
commit’s transaction, throwingQuotaExceeded(adapter contract). - Holds.
holdandextendcheck the reserved quantity;captureis never refused (a sent SMS must be recorded). - Not seats. Counters only grow. Limits on levels that go up and down, like seats, live in the app (A108):
export class SeatRegistry { #seats = new Map<string, number>(); /** Level changes, kept for `integrate()` at cycle close. */ readonly samples = new Map<string, Sample[]>();
constructor(private purchased: (accountId: string) => number) {}
current(accountId: string): number { return this.#seats.get(accountId) ?? 0; }
/** All or nothing: 9 of 10 used and 4 requested → refused, `remaining: 1` tells the UI what fits. */ add(accountId: string, count: number, at: number): AddSeats { // in production: one DB transaction (SELECT … FOR UPDATE, or a Durable Object per account) const current = this.current(accountId); const purchased = this.purchased(accountId); if (current + count > purchased) return { ok: false, reason: "seat_limit", purchased, current, remaining: purchased - current }; return this.#set(accountId, current + count, at); }
remove(accountId: string, count: number, at: number): AddSeats { return this.#set(accountId, Math.max(0, this.current(accountId) - count), at); }
#set(accountId: string, seats: number, at: number): AddSeats { this.#seats.set(accountId, seats); const s = this.samples.get(accountId) ?? []; s.push({ at, value: seats }); this.samples.set(accountId, s); return { ok: true, seats }; }}