Skip to content

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 }.

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
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).

docs/src/snippets/plan-and-hold.test.ts
// 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) = 71000
h.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 hold is a multi-line reservation. Each line’s tariff and usedSoFar are 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 same refId and is a no-op in commit.
  • 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 as feeders[]. The hold rounds each key once: under cumulative it equals an observe with separate lines exactly; under per_event_up it differs only by rounding (at most 2 micro-units per line), in either direction.
  • extend grows the hold: existing (meter, dims) lines grow on their stored tariff, getRate is called only for new lines, and pool lines follow their feeders.
  • capture charges what actually happened. The line is found by meter; when the same meter is on several lines, dims is required. Pool lines are captured along with their feeders. Partial and repeated captures are fine.
  • release frees the remaining reservation (upperBound − captured) and closes the hold.
docs/src/snippets/plan-and-hold.test.ts
// 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.

docs/src/snippets/plan-and-hold.test.ts
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.

docs/src/snippets/plan-and-hold.test.ts
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.

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

“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).

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):

docs/src/snippets/plan-and-hold.test.ts
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 10
const 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.used is what’s left.
  • Per meter. Lines of the same meter add up, whatever their dims. limit: 0 means the meter is not in the plan (unlike no_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, throwing QuotaExceeded (adapter contract).
  • Holds. hold and extend check the reserved quantity; capture is 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):
examples/saas-seats/src/seats.ts
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 };
}
}