Early access for Channel Partners and ISVs opening soon. Learn more →

Type-level multi-tenancy: making "forgot the WHERE tenant_id" impossible

Engineering

Type-level multi-tenancy: making "forgot the WHERE tenant_id" impossible

FastYoke Engineering · 8 min read · Jul 6, 2026

  • Architecture
  • Multi-tenancy
  • Rust
  • Security

The one bug we refuse to ship

Every multi-tenant system has a single worst bug, and it's always the same one: a query that returns the wrong organization's data. Not a crash, not a slow page — a SELECT that quietly hands tenant B a list of tenant A's jobs because someone, somewhere, wrote WHERE id = ? when they meant WHERE tenant_id = ? AND id = ?. It doesn't throw. It doesn't log an error. It returns a perfectly well-formed response full of data the caller was never allowed to see.

In FastYoke, one process holds the state machines, schemas, and request handlers for every organization on that node. That shared-process design buys us density and operational simplicity, but it means the tenant boundary is drawn in software rather than by separate machines. So the rule that every access to tenant data is scoped to the tenant that owns it isn't a nice-to-have — it's the invariant the whole platform rests on. And an invariant that important is too important to leave to code review.

This post is about how we push that rule down into the type system, so that a query which forgets its tenant scope tends not to compile in the first place.

Why "just remember the WHERE clause" doesn't scale

The obvious way to enforce tenant scoping is a convention: every query includes WHERE tenant_id = ?, and reviewers watch for it. We do have that convention, and reviewers do watch for it. But a convention has three weaknesses that get worse the larger the codebase grows.

First, it's invisible at the call site. Nothing about db.query("SELECT …") tells you whether the string that follows is scoped or not; you have to read the SQL every time. Second, it depends on human attention under deadline pressure — exactly the condition where a filter clause gets dropped during a refactor and nobody notices, because the tests all run in a single-tenant fixture where the missing filter changes nothing. Third, the compiler is completely silent. The one tool that reads every line of our code, every build, with no fatigue and no deadline, has been given no way to help.

That last point is the opening. If the compiler is going to help, the tenant scope has to be something it can see — a value with a type, not a substring buried in a query.

Make the tenant scope a value you can't fake

The first move is to stop passing tenant identity around as a bare string. A tenant id is not a String and not an i64; it's a TenantId — a newtype whose only honest source is the authenticated request. We mint it from the verified JWT claims during request extraction and nowhere else. It is never read from a request body, a query parameter, or a header the caller controls, because those are all things an attacker can set. A handler that wants to know which tenant it's serving asks for a TenantId, and the only way to obtain one is to have passed authentication.

That alone kills a whole family of confused-deputy bugs — the ones where a request says "operate on tenant 7" and the server believes it. But a TenantId sitting in a variable doesn't help if a query can still ignore it. The second move is to make the scope structural: the only way to reach tenant data is through a handle that already carries the tenant.

A scoped handle, not a raw pool

Route handlers in FastYoke don't get a raw database pool. They get a tenant-scoped executor — think of it as a TenantDb that owns both the connection and the TenantId, handed to the handler already bound to the authenticated tenant. Every data-access function takes &TenantDb, and those functions are the only path to the tables. When one of them builds a statement, it threads the handle's tenant id into the parameters itself, so the WHERE tenant_id = ? binding isn't something a caller remembers to add — it's something the scoped executor supplies on every call.

Now the tenant scope is visible to the compiler. A function that needs to touch tenant data has to name &TenantDb in its signature, and to call it you must already hold a scoped handle, which means you must already hold a TenantId, which means you must have authenticated. The chain from "an authenticated request arrived" to "this exact tenant's rows" is one the type checker can follow end to end. Drop a link — try to run a query with no tenant in hand — and the code doesn't type-check, at your desk, in seconds, instead of in production, silently, for weeks.

The escape hatches, made loud on purpose

Some queries genuinely are platform-wide: billing rollups, cross-tenant routing, the audit and metering state that belongs to the platform rather than to any one organization. Pretending those don't exist would just push people back toward the raw pool and defeat the whole exercise. So instead of hiding the exception, we make it loud.

Platform-scoped access goes through a separate, explicitly-named path — a distinct handle for the platform datastore, not the tenant executor with its guard removed. Because it's a different type with a deliberately conspicuous name, it's trivially greppable in review and it reads as a decision at the call site: this query is platform-wide, on purpose. The administrative override that forces a job to a terminal state is the same philosophy from the other direction — it still resolves the tenant from the operator's authenticated claims, never from the request body, so even the "bypass the normal path" path can't be tricked into crossing tenants.

The goal was never to make cross-tenant queries impossible; some are legitimate. It was to make them unmistakable — to turn "did this author mean to leave the tenant scope?" from a question a reviewer has to reconstruct from SQL into an answer the types state outright.

Types narrow the surface; they don't replace the rest

It would be dishonest to claim the type system carries this alone. What it does is shrink the area where a mistake can hide, so the remaining defenses have far less to cover. The same instinct runs through the rest of the request path: handlers don't get to .unwrap() or .expect(), because a panic in a shared process is everyone's outage, not just the offending tenant's — every fallible path returns a typed error that becomes a structured JSON response. On top of that sit the classics: tests that assert one tenant cannot read another's rows, and review that now has a much smaller, much more legible thing to look for.

The difference is where the work lands. With a scoped-handle design, the default path is the safe path, and unsafe access is the thing you have to write on purpose and defend out loud. Correctness stops being a property you maintain by vigilance and becomes, mostly, a property the compiler maintains for you — which is exactly the trade we want for the one bug we refuse to ship.

Where to go next

Type-level tenant scoping is one expression of a broader choice: let the toolchain hold the invariants we care about most. That choice starts with the language itself — we wrote about why the core is Rust in Why the FastYoke core is written in Rust, and about how the same isolation boundary shows up for physically separate locations in Per-location isolation.

If you want to see what building on that boundary looks like for your own data, take a look at FastYoke's runtime or talk to us about building on FastYoke.