ConceptsBrands & multi-tenancy

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”

  1. Brand as configuration: name, logo, primary color, default email sender, vendor sub-accounts, compliance regime. Lives in the platform.brands table (read by @platform/brands).
  2. Brand as data partition: every row in every service’s schema has a brand_id column. 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

LayerHow brand is carried
HTTP requestSubdomain (loop.healthbrand_id=loop) OR explicit header (X-Brand-Id)
Access tokenbrand_id claim, derived from the user’s primary brand at sign-in
Database rowbrand_id column (NOT NULL, enforced by migration check)
Audit logbrand_id column on every audit row
Event payloadbrand_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:

  1. Reads brand_id from the access token (preferred) or X-Brand-Id header (M2M tokens may set this explicitly).
  2. Validates the brand exists in platform.brands via BrandsCatalog.
  3. Attaches c.req.auth.brand_id to 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

FeaturePer-brand?Where
Display name + logoYesplatform.brands
Email sender domainYesplatform.brands.email_from
Twilio subaccountYesplatform.brands.twilio_subaccount
Compliance regimeYesplatform.brands.compliance_regime (used by audit + PHI handling)
OAuth consent screenYes (brand-themed)rendered server-side
Available scopesCurrently no — all brands offer same scopesCould be added if needed
Service availabilityCurrently noCould 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 = ? (no brand_id filter) is a bug.
  • A staff user with permission to manage brand=loop cannot 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=loop should 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

  1. Add a row to platform.brands via the seed script + migration.
  2. Add a constant to BRAND_IDS in @platform/brands/src/brand-ids.ts.
  3. Configure the new brand’s vendor sub-accounts (Twilio, Postmark, Stripe Connect platform, BigCommerce store).
  4. Add the brand’s DNS subdomain in Cloudflare.
  5. Verify the OAuth consent screen renders with the new brand’s logo / color.
  6. Smoke test: create a test user in the new brand, log in, hit a couple of services, confirm brand_id flows correctly.

This sequence is documented in the runbook at docs/runbooks/add-new-brand.md.

Common mistakes

  • Forgetting brand_id in 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". Use BRAND_IDS.LOOP from @platform/brands for 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.

Source ADRs

ADR-0038 (brands as platform config), ADR-0046 (PHI safe-views), ADR-0039 (audit logs).