Domain model & invariants
The engine exports its registry as meteringEntities — metering-meter, metering-entry and metering-period — so a vertical composes it by passing the registry to defineOperations and never retypes a shape (see Composing engines).
Tables
metering_meters (
key TEXT PRIMARY KEY, -- vertical vocabulary: 'ai.tokens.input' | 'storage.bytes' | …
kind TEXT NOT NULL, -- counter | gauge — FROZEN after creation
unit TEXT NOT NULL, -- 'tokens' | 'bytes' | 'requests' — FROZEN after creation
description TEXT,
active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL
)
metering_entries ( -- APPEND-ONLY. No UPDATE. No DELETE. Ever.
id TEXT PRIMARY KEY, -- ulid()
meter_key TEXT NOT NULL,
qty TEXT NOT NULL, -- decimal string; SIGNED for counters, >= 0 for gauges
subject_type TEXT, -- optional opaque attribution ref, stored as its pair
subject_id TEXT,
occurred_at TEXT NOT NULL, -- UTC instant, normalized to ms precision
dedupe_key TEXT NOT NULL, -- caller-supplied idempotency key
note TEXT,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (meter_key, dedupe_key) -- THE invariant: one observation, one row
)
metering_periods ( -- append-only close journal; monotonic, non-overlapping
id TEXT PRIMARY KEY, -- ulid()
from_at TEXT NOT NULL, -- half-open [from, to)
to_at TEXT NOT NULL, -- MAX(to_at) = the close horizon
closed_by TEXT NOT NULL,
closed_at TEXT NOT NULL
)
metering_period_lines ( -- the frozen aggregates; unpriced by design
id TEXT PRIMARY KEY,
period_id TEXT NOT NULL,
meter_key TEXT NOT NULL,
kind TEXT NOT NULL, -- snapshot of the meter at close
unit TEXT NOT NULL,
qty TEXT NOT NULL,
entry_count INTEGER NOT NULL -- 0 = a gauge's carried-forward level
)Three deliberate shapes:
- The dedupe key is unique per meter, not globally — one builder turn records
ai.tokens.inputandai.tokens.outputunder the same turn id. - Instants are UTC ISO-8601 normalized to millisecond precision on the way in, so lexicographic order is chronological order — mixed precision (
…T00:00:00Zvs…T00:00:00.000Z) would silently break every window comparison. - Quantities are money-style decimal strings folded with
addDecimal/compareDecimal— never floats.
The two meter kinds
| Kind | Quantity | Window aggregate | Empty window |
|---|---|---|---|
counter | signed delta (a correction is a negative entry) | Σ qty over [from, to) | omitted — a zero sum bills nothing |
gauge | non-negative level sample | max sample in [from, to) | carries forward the latest earlier sample, entryCount: 0; omitted only if never sampled |
The kind and unit live on the meter definition and are frozen after creation — changing a unit mid-period would corrupt every aggregate that spans the change. A new unit is a new meter key.
The invariants
- Append-only — nothing in the engine issues
UPDATEorDELETEagainstmetering_entries. Corrections compensate; history survives. - One observation, one row, one event —
recordUsagewith a seen(meter, dedupeKey)and the same qty returns the existing entry and emits nothing; with a different qty it throws. That branch returns before eitheroccurred_atbound below is consulted, so a retry of an already-recorded key never starts failing because the horizon moved in the meantime. - One aggregation code path —
usageTotal(the preview read) andclosePeriod(the freeze) share the same internal aggregation, so a preview can never disagree with the eventual line. - Closes are monotonic and non-overlapping — a new period's
frommust be at or after the latest closedto. Gaps are allowed (metering may start mid-life); rewinds are not. - Nothing lands outside the window around now —
recordUsageboundsoccurred_aton both sides. Behind, it refuses anything before the latest closedto(period_closed), so closed lines stay reproducible from their entries forever. Ahead, it refuses anything more than five minutes past the operation's own instant (occurred_at_ahead) — because the horizon only ever moves forward, an entry post-dated beyond it is aggregated by no close at all and would leave the billing stream with no error anywhere. The forward bound is a clock-skew tolerance, deliberately small. Either way, usage is recorded at observation time (occurred_atdefaults to now). - Every mutation emits a fat event; every operation checks a permission.
Instants, not days
Windows are half-open [from, to) UTC instants — the booking engine's convention, deliberately unlike the absence engine's inclusive calendar days. Usage is machine-shaped: a month's window is [2026-08-01T00:00:00Z, 2026-09-01T00:00:00Z) and an entry at exactly the boundary belongs to exactly one period — the next one. The instant at the horizon is the first legal occurred_at after a close.