Multi-tenancy: what billing-kit assumes, and how to make it true
Synced from billing-kit/docs/MULTI_TENANCY.md — the repo is canonical.
Every ingest row in this library carries a tenantId. It is on the first
usage event in the README, inside the idempotency key, on every subscription
and ledger row. This doc says out loud what the rest of the documentation
assumes about it — and points at the sibling library whose job is making the
assumption hold.
The assumption
tenantId is established upstream, never taken from the request.
billing-kit treats the tenant id the way it treats SubjectId: an opaque
string whose meaning the caller owns. The library scopes queries by it,
partitions dedupe by it, and never verifies it — it cannot verify it,
because it deliberately has no idea what a tenant is. A tenantId copied
from a request body or an unauthenticated header into record() is the same
bug the SubjectId docs warn about for provider customer ids — the caller
names someone else's account and the system obliges — reproduced one field
over, with a ledger attached.
So the contract at this seam is one sentence: by the time a value reaches
billing-kit as a tenantId, something has already checked that the caller
belongs to that tenant.
The family's answer: tenant-kit
tenant-kit is that something — the sibling library that owns what this one abstains from: a tenant directory (slugs, names, archive-not-delete), memberships and roles, request→tenant resolution split into untrusted extraction and store-backed authorization, an ambient tenant context, and database-enforced isolation via forced row-level security.
The integration is two shared shapes, not an import:
TenantIdis the same opaquetextin both vocabularies and both schemas. A tenant resolved there is atenantIdhere, no casts.SqlExecutoris structurally identical in both libraries. Onepg.Pooladapter (~10 lines) serves both;tenancy.*andbilling.*live in one database under one transaction discipline.
Wired together, the request path reads:
const resolved = await tenancy.resolve(req, { userId, extract }); // membership checked
tenancy.run(resolved, async () => {
await billing.record({
tenantId: tenancy.require().tenantId, // resolved, not request-supplied
subjectId: userId,
source: 'api',
externalId: req.id,
metric: 'tokens.input',
quantity: Quantity.fromBigInt(n),
occurredAt: new Date(),
});
});
The full walk-through — including protecting billing.* tables with
tenant-kit's row-level security, and the whale-tenant-on-its-own-database
pattern both kits support through the shared executor — lives in
tenant-kit's docs/BILLING_KIT.md.
One caution that belongs on this side of the seam
billing-kit's cross-tenant machinery — the metering drain, the subscription due-sweep — iterates all tenants by design. If you scope executors per tenant (row-level security or routing), those workers must run on an unscoped executor. A due-sweep on a tenant-scoped executor does not fail; it quietly charges one tenant and skips the rest, which is revenue silently not collected. Request-path billing calls take the scoped executor; worker-path calls take the unscoped one. Hold that line in review — the executor variable's name is the audit trail.
Why billing-kit will not grow this itself
The same reason it will not grow a payment provider: the tenant boundary is a place where being a library you embed only works if each library owns one thing. Tenancy needs its own directory, its own invariants (a tenant always has an owner; archived tenants keep resolving in the ledger), and its own security argument (extraction vs authorization). Folding that in here would make every billing adopter carry an org model they may already have — exactly what this library exists to not do to people. The seam stays; the sibling owns the other side.