Brands and multi-tenancy
What this is: the platform serves multiple brands (Loop, LoopBio, future ones) from one codebase, one cluster, one database. The mechanism is a brand_id column on every row and middleware that enforces it.
Who it’s for: anyone writing a service that touches user data, anyone designing a new feature that has different behavior per brand, anyone debugging “why does my query return zero rows?”
What to read next: System overview, PHI and audit, @platform/brands reference.
The model
Two distinct meanings of “brand”
- Brand as configuration: name, logo, primary color, default email sender, vendor sub-accounts, compliance regime. Lives in the
platform.brandstable (read by@platform/brands). - Brand as data partition: every row in every service’s schema has a
brand_idcolumn. A user belongs to a brand. A subscription belongs to a brand. A biomarker belongs to a brand. Data never crosses the boundary.
These are tightly linked but distinct. The first is config; the second is enforcement.
What carries brand context
| Layer | How brand is carried |
|---|---|
| HTTP request | Subdomain (loop.health → brand_id=loop) OR explicit header (X-Brand-Id) |
| Access token | brand_id claim, derived from the user’s primary brand at sign-in |
| Database row | brand_id column (NOT NULL, enforced by migration check) |
| Audit log | brand_id column on every audit row |
| Event payload | brand_id field where relevant (e.g., order.placed.v1.brand_id) |
Enforcement
The middleware at @platform/hono/src/middleware/brand-scope.ts does three things:
- Reads
brand_idfrom the access token (preferred) orX-Brand-Idheader (M2M tokens may set this explicitly). - Validates the brand exists in
platform.brandsviaBrandsCatalog. - Attaches
c.req.auth.brand_idto the Hono context for downstream use.
Repositories (per ADR-0037) MUST filter by brand_id on every read. The convention check migration-brand-id-required rejects any new table without a brand_id column.
// services/clinical/src/db/biomarker.repository.ts
async findByUser(userId: string, brandId: string) {
return this.db()
.select()
.from(biomarkers)
.where(and(eq(biomarkers.userId, userId), eq(biomarkers.brandId, brandId)));
}A repository method that forgets the brand_id filter is a security incident. PR reviews are the primary defense; the test suite for any new repo method should assert that a row from a different brand is NOT returned.
When user input claims a brand
User input that arrives over the wire still comes in as a string. Always validate it against platform.brands (via BrandsCatalog.exists()) before persisting. Don’t trust that the string "loop" is a valid brand id — even though it is today.
For typed code references (defaults, log fields, conditionals), use BRAND_IDS.LOOP / BRAND_IDS.LOOPBIO from @platform/brands. These guard against typos in code paths that aren’t string-input-driven.
import { BRAND_IDS } from "@platform/brands";
if (brand === BRAND_IDS.LOOPBIO) {
// route to LoopBio-specific clinician pool
}What brands can differ on
| Feature | Per-brand? | Where |
|---|---|---|
| Display name + logo | Yes | platform.brands |
| Email sender domain | Yes | platform.brands.email_from |
| Twilio subaccount | Yes | platform.brands.twilio_subaccount |
| Compliance regime | Yes | platform.brands.compliance_regime (used by audit + PHI handling) |
| OAuth consent screen | Yes (brand-themed) | rendered server-side |
| Available scopes | Currently no — all brands offer same scopes | Could be added if needed |
| Service availability | Currently no | Could be added via feature flags |
The pattern: add a per-brand config field when behavior must vary, but never partition the codebase by brand. One codebase, one binary, branching on brand_id only when the behavior actually differs.
Cross-brand data is forbidden
Concretely:
- A query like
SELECT * FROM biomarkers WHERE user_id = ?(nobrand_idfilter) is a bug. - A staff user with permission to manage
brand=loopcannot read brand=loopbio data via any normal API path. Cross-brand staff actions require an explicit admin tool that audit-logs the cross-brand access. - Events published by service A with
brand_id=loopshould not trigger handlers in service B that don’t filter on the same brand.
The audit-log convention makes cross-brand reads visible: any read where actor.brand_id != target.brand_id should fire an alarm. Today this is manual review; the alarm is on the roadmap.
Adding a new brand
- Add a row to
platform.brandsvia the seed script + migration. - Add a constant to
BRAND_IDSin@platform/brands/src/brand-ids.ts. - Configure the new brand’s vendor sub-accounts (Twilio, Postmark, Stripe Connect platform, BigCommerce store).
- Add the brand’s DNS subdomain in Cloudflare.
- Verify the OAuth consent screen renders with the new brand’s logo / color.
- Smoke test: create a test user in the new brand, log in, hit a couple of services, confirm
brand_idflows correctly.
This sequence is documented in the runbook at docs/runbooks/add-new-brand.md.
Common mistakes
- Forgetting
brand_idin a new repository method. Catches in PR review or production via empty results. Make the test that explicitly inserts rows for two brands and asserts the filter works. - Hardcoding
"loop". UseBRAND_IDS.LOOPfrom@platform/brandsfor code references. Use user input only after validation. - Assuming brand from subdomain alone. Subdomain is a hint; the access token is the source of truth.
- Cross-brand data via admin tool without audit. If staff needs to access another brand’s data, that action MUST write an audit row with the cross-brand intent recorded.
Related
Source ADRs
ADR-0038 (brands as platform config), ADR-0046 (PHI safe-views), ADR-0039 (audit logs).